implimented multiple VPN connectors
This commit is contained in:
@@ -16,6 +16,12 @@ Item {
|
|||||||
// icon sits in the row.
|
// icon sits in the row.
|
||||||
required property var barWindow
|
required property var barWindow
|
||||||
|
|
||||||
|
// How often to poll `tailscale status` in the background, in seconds.
|
||||||
|
// Overridable per-instance; clamped so a bad value can't hammer the CLI
|
||||||
|
// or effectively stop refreshing.
|
||||||
|
property int refreshIntervalSec: 30
|
||||||
|
readonly property int _refreshIntervalMs: Math.max(5, Math.min(3600, refreshIntervalSec)) * 1000
|
||||||
|
|
||||||
// Quickshell has no native Tailscale API — this shells out to the
|
// Quickshell has no native Tailscale API — this shells out to the
|
||||||
// `tailscale` CLI, the same way NetworkIndicator.qml gets its live
|
// `tailscale` CLI, the same way NetworkIndicator.qml gets its live
|
||||||
// stats. `status --json` gives a full snapshot in one call.
|
// stats. `status --json` gives a full snapshot in one call.
|
||||||
@@ -27,6 +33,38 @@ Item {
|
|||||||
property var status: defaultStatus
|
property var status: defaultStatus
|
||||||
property bool busy: false
|
property bool busy: false
|
||||||
|
|
||||||
|
// Whether the `tailscale` binary itself is on PATH — distinct from
|
||||||
|
// whether the daemon is running/connected. Starts optimistic so the
|
||||||
|
// panel doesn't flash a "not installed" message before the first check
|
||||||
|
// (whichProc, below) has had a chance to land.
|
||||||
|
property bool installed: true
|
||||||
|
|
||||||
|
// Optimistic desired power state, so the header switch flips the
|
||||||
|
// instant you click it instead of waiting on the next poll. -1 means
|
||||||
|
// "no override, just follow root.status.running"; 0/1 means a toggle
|
||||||
|
// is in flight and hasn't been confirmed by a poll yet.
|
||||||
|
property int _desired: -1
|
||||||
|
readonly property bool active: _desired === -1 ? root.status.running : (_desired === 1)
|
||||||
|
|
||||||
|
// Surfaced under the header for toggle failures and the login-link
|
||||||
|
// timeout; cleared automatically after a few seconds.
|
||||||
|
property string actionStatus: ""
|
||||||
|
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 Mullvad/ProtonVPN disconnect,
|
||||||
|
// without overriding a deliberate manual "off".
|
||||||
|
property bool _forcedDownByVpn: false
|
||||||
|
|
||||||
|
// Other logged-in Tailscale accounts on this machine (`tailscale switch
|
||||||
|
// --list`) — separate tailnets/identities you can flip between, not
|
||||||
|
// logins for other VPN products. Only ever shown when there's more than
|
||||||
|
// one, same as omarchy's panel.
|
||||||
|
property var accounts: []
|
||||||
|
property string switchingAccountId: ""
|
||||||
|
|
||||||
function parseTailscaleStatus(text) {
|
function parseTailscaleStatus(text) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(text)
|
const data = JSON.parse(text)
|
||||||
@@ -41,10 +79,14 @@ Item {
|
|||||||
const name = (p.HostName || "").trim()
|
const name = (p.HostName || "").trim()
|
||||||
if (!name) continue
|
if (!name) continue
|
||||||
const ips = (p.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) })
|
const ips = (p.TailscaleIPs || []).filter(function(ip) { return /^100\./.test(ip) })
|
||||||
|
const ip6s = (p.TailscaleIPs || []).filter(function(ip) { return /^fd7a:115c:a1e0:/i.test(ip) })
|
||||||
|
const dnsName = String(p.DNSName || "").replace(/\.$/, "")
|
||||||
peers.push({
|
peers.push({
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
ip: ips.length > 0 ? ips[0] : "",
|
ip: ips.length > 0 ? ips[0] : "",
|
||||||
|
ip6: ip6s.length > 0 ? ip6s[0] : "",
|
||||||
|
dnsName: dnsName,
|
||||||
os: (p.OS || "").toLowerCase(),
|
os: (p.OS || "").toLowerCase(),
|
||||||
exitNodeOption: !!p.ExitNodeOption,
|
exitNodeOption: !!p.ExitNodeOption,
|
||||||
exitNodeActive: !!p.ExitNode
|
exitNodeActive: !!p.ExitNode
|
||||||
@@ -76,12 +118,98 @@ Item {
|
|||||||
return "devices_other"
|
return "devices_other"
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTailscale() {
|
function accountLabel(account) {
|
||||||
if (root.busy) return
|
if (!account) return "Unknown account"
|
||||||
|
return account.nickname || account.tailnet || account.account || account.id || "Unknown account"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAccounts(text) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
return data.map(function(a) {
|
||||||
|
return {
|
||||||
|
id: String(a.id || a.ID || ""),
|
||||||
|
nickname: String(a.nickname || a.Nickname || ""),
|
||||||
|
tailnet: String(a.tailnet || a.Tailnet || ""),
|
||||||
|
account: String(a.account || a.Account || ""),
|
||||||
|
selected: a.selected === true || a.Selected === true
|
||||||
|
}
|
||||||
|
}).filter(function(a) { return a.id !== "" })
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies a freshly-parsed status snapshot and reacts to what changed:
|
||||||
|
// clears the optimistic toggle once reality catches up, and auto-opens
|
||||||
|
// a login link the moment the daemon reports one.
|
||||||
|
function applyStatus(text) {
|
||||||
|
const next = root.parseTailscaleStatus(text)
|
||||||
|
root.status = next
|
||||||
|
if (root._desired !== -1 && next.running === (root._desired === 1)) root._desired = -1
|
||||||
|
if (next.running) {
|
||||||
|
root._loginPending = false
|
||||||
|
root._authUrlOpened = false
|
||||||
|
loginTimeoutTimer.stop()
|
||||||
|
// Tailscale, Mullvad, and ProtonVPN all fight over the default
|
||||||
|
// route/DNS if more than one is active — see VpnIndicator.qml.
|
||||||
|
// Tailscale is the one that backs off, rather than leave you
|
||||||
|
// with a broken connection until you notice and fix it by hand.
|
||||||
|
if (VpnExclusivity.commercialVpnActive && !root.busy) {
|
||||||
root.busy = true
|
root.busy = true
|
||||||
actionProc.command = root.status.running ? ["tailscale", "down"] : ["tailscale", "up"]
|
root._desired = 0
|
||||||
|
root._forcedDownByVpn = true
|
||||||
|
root.actionStatus = "Tailscale stopped — a VPN provider is active"
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
actionProc.command = ["tailscale", "down"]
|
||||||
actionProc.running = true
|
actionProc.running = true
|
||||||
}
|
}
|
||||||
|
} else if (next.needsLogin && next.authUrl !== "" && !root._authUrlOpened) {
|
||||||
|
root.openAuthUrl(next.authUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAuthUrl(url) {
|
||||||
|
if (root._authUrlOpened || !url) return
|
||||||
|
root._authUrlOpened = true
|
||||||
|
root._loginPending = false
|
||||||
|
loginTimeoutTimer.stop()
|
||||||
|
Qt.openUrlExternally(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bringTailscaleUp() {
|
||||||
|
if (root.busy || !root.installed) return
|
||||||
|
root.busy = true
|
||||||
|
root._desired = 1
|
||||||
|
root._loginPending = true
|
||||||
|
root._authUrlOpened = false
|
||||||
|
root.actionStatus = ""
|
||||||
|
actionProc.command = ["tailscale", "up"]
|
||||||
|
loginTimeoutTimer.restart()
|
||||||
|
actionProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (root.active) {
|
||||||
|
root.busy = true
|
||||||
|
root._desired = 0
|
||||||
|
actionProc.command = ["tailscale", "down"]
|
||||||
|
actionProc.running = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (VpnExclusivity.commercialVpnActive) {
|
||||||
|
root.actionStatus = "Disconnect Mullvad/ProtonVPN first"
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.bringTailscaleUp()
|
||||||
|
}
|
||||||
|
|
||||||
function setExitNode(peer) {
|
function setExitNode(peer) {
|
||||||
if (root.busy) return
|
if (root.busy) return
|
||||||
@@ -94,20 +222,68 @@ Item {
|
|||||||
if (text) Quickshell.clipboardText = text
|
if (text) Quickshell.clipboardText = text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchAccount(id) {
|
||||||
|
if (root.busy || !id || root.switchingAccountId !== "" || switchProc.running) return
|
||||||
|
root.busy = true
|
||||||
|
root.switchingAccountId = id
|
||||||
|
switchProc.command = ["tailscale", "switch", id]
|
||||||
|
switchProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!root.installed) {
|
||||||
|
if (!whichProc.running) whichProc.running = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!statusProc.running) {
|
||||||
|
statusProc.running = true
|
||||||
|
pollWatchdog.restart()
|
||||||
|
}
|
||||||
|
if (!accountsProc.running) accountsProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detects whether the CLI is on PATH at all, separately from whether a
|
||||||
|
// poll succeeded — a poll can fail for lots of reasons (daemon down,
|
||||||
|
// permissions), but only this tells us the binary is actually missing.
|
||||||
|
Process {
|
||||||
|
id: whichProc
|
||||||
|
command: ["which", "tailscale"]
|
||||||
|
running: true
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.installed = exitCode === 0
|
||||||
|
if (root.installed) root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: statusProc
|
id: statusProc
|
||||||
command: ["tailscale", "status", "--json"]
|
command: ["tailscale", "status", "--json"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
id: statusCollector
|
id: statusCollector
|
||||||
onStreamFinished: root.status = root.parseTailscaleStatus(statusCollector.text)
|
onStreamFinished: root.applyStatus(statusCollector.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
// A poll that fails outright is worth double-checking against
|
||||||
|
// `which` — covers the CLI having been uninstalled after this
|
||||||
|
// widget started up.
|
||||||
|
if (exitCode !== 0 && !whichProc.running) whichProc.running = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: actionProc
|
id: actionProc
|
||||||
stdout: StdioCollector {}
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector {}
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
root.busy = false
|
root.busy = false
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root._desired = -1
|
||||||
|
root._loginPending = false
|
||||||
|
loginTimeoutTimer.stop()
|
||||||
|
root.actionStatus = "Tailscale command failed"
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
if (!statusProc.running) statusProc.running = true
|
if (!statusProc.running) statusProc.running = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +297,104 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: accountsProc
|
||||||
|
command: ["tailscale", "switch", "--list", "--json"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: accountsCollector
|
||||||
|
onStreamFinished: root.accounts = root.parseAccounts(accountsCollector.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) root.accounts = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: switchProc
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.busy = false
|
||||||
|
root.switchingAccountId = ""
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "Account switch failed"
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
if (!statusProc.running) statusProc.running = true
|
||||||
|
if (!accountsProc.running) accountsProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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".
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background poll, independent of whether the panel is open, so the bar
|
||||||
|
// icon itself stays live.
|
||||||
Timer {
|
Timer {
|
||||||
interval: 3000
|
id: refreshTimer
|
||||||
|
interval: root._refreshIntervalMs
|
||||||
repeat: true
|
repeat: true
|
||||||
|
running: true
|
||||||
triggeredOnStart: true
|
triggeredOnStart: true
|
||||||
running: panel.visible
|
onTriggered: root.refresh()
|
||||||
onTriggered: if (!statusProc.running) statusProc.running = true
|
}
|
||||||
|
|
||||||
|
// Right after launch tailscaled often hasn't come up yet — poll faster
|
||||||
|
// for the first ~30s until it reports running, instead of waiting out a
|
||||||
|
// full refresh interval.
|
||||||
|
Timer {
|
||||||
|
id: startupRamp
|
||||||
|
property int ticks: 0
|
||||||
|
interval: 2000
|
||||||
|
repeat: true
|
||||||
|
running: true
|
||||||
|
onTriggered: {
|
||||||
|
ticks += 1
|
||||||
|
if (root.status.running || ticks >= 15) running = false
|
||||||
|
else root.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every poll is skipped while its own process is still running, so one
|
||||||
|
// that never exits (a flaky network can make `tailscale status` hang)
|
||||||
|
// would silently stop the panel refreshing at all. Reap it well inside
|
||||||
|
// the refresh interval so the next tick starts clean.
|
||||||
|
Timer {
|
||||||
|
id: pollWatchdog
|
||||||
|
interval: 15000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: if (statusProc.running) statusProc.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: actionStatusTimer
|
||||||
|
interval: 4000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.actionStatus = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: loginTimeoutTimer
|
||||||
|
interval: 10000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: {
|
||||||
|
if (!root._loginPending || root._authUrlOpened) return
|
||||||
|
root._loginPending = false
|
||||||
|
root.actionStatus = "Tailscale login link not available yet"
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The real Tailscale mark: a 3x3 grid of dots where the center dot and
|
// The real Tailscale mark: a 3x3 grid of dots where the center dot and
|
||||||
@@ -135,11 +403,15 @@ Item {
|
|||||||
// glyph. Built from plain circles rather than the brand SVG (which
|
// glyph. Built from plain circles rather than the brand SVG (which
|
||||||
// encodes that dimming as fixed opacity on specific dots, unrelated to
|
// encodes that dimming as fixed opacity on specific dots, unrelated to
|
||||||
// app state) so the full-color dots can still recolor with connection
|
// app state) so the full-color dots can still recolor with connection
|
||||||
// state.
|
// state. A diagonal slash overlays it when fully off, and a small
|
||||||
|
// warning badge appears when the daemon wants a login.
|
||||||
component TailscaleLogo: Item {
|
component TailscaleLogo: Item {
|
||||||
id: logo
|
id: logo
|
||||||
property color color: Colors.foreground
|
property color color: Colors.foreground
|
||||||
property real size: 16
|
property real size: 16
|
||||||
|
property bool crossed: false
|
||||||
|
property bool warning: false
|
||||||
|
property color badgeColor: Colors.urgent
|
||||||
implicitWidth: size
|
implicitWidth: size
|
||||||
implicitHeight: size
|
implicitHeight: size
|
||||||
|
|
||||||
@@ -167,13 +439,46 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: logo.crossed
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width * 1.2
|
||||||
|
height: Math.max(1.5, parent.height * 0.13)
|
||||||
|
radius: height / 2
|
||||||
|
color: logo.color
|
||||||
|
rotation: -45
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: logo.warning
|
||||||
|
width: Math.max(7, logo.size * 0.42)
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: logo.badgeColor
|
||||||
|
border.color: Colors.surface
|
||||||
|
border.width: 1
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "!"
|
||||||
|
color: Colors.surface
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Math.max(6, parent.height * 0.75)
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TailscaleLogo {
|
TailscaleLogo {
|
||||||
id: label
|
id: label
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
size: 16
|
size: 16
|
||||||
color: root.status.running ? Colors.foreground : Colors.muted
|
color: root.active ? Colors.foreground : Colors.muted
|
||||||
|
crossed: !root.active && !root.status.needsLogin
|
||||||
|
warning: root.status.needsLogin
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
@@ -279,6 +584,201 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A logged-in Tailscale account row — click to switch to it. Spins its
|
||||||
|
// glyph while the switch is in flight, same convention as the exit-node
|
||||||
|
// rotation below.
|
||||||
|
component AccountRow: Rectangle {
|
||||||
|
id: accountRow
|
||||||
|
property var account: null
|
||||||
|
readonly property bool selected: accountRow.account && accountRow.account.selected === true
|
||||||
|
readonly property bool switching: accountRow.account && root.switchingAccountId === accountRow.account.id
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
radius: 0
|
||||||
|
implicitHeight: accountContent.implicitHeight + 12
|
||||||
|
color: accountRow.selected
|
||||||
|
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
||||||
|
: (accountMouse.containsMouse ? Colors.primary : "transparent")
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: accountContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 20
|
||||||
|
Layout.minimumWidth: 20
|
||||||
|
Layout.maximumWidth: 20
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
text: "person"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
opacity: accountRow.switching ? 0.45 : 1
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: accountRow.switching
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 1.0; duration: 420; easing.type: Easing.InOutQuad }
|
||||||
|
NumberAnimation { to: 0.45; duration: 420; easing.type: Easing.InOutQuad }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.accountLabel(accountRow.account)
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
font.bold: accountRow.selected
|
||||||
|
color: accountMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: accountRow.selected
|
||||||
|
text: "check_circle"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: accountMouse.containsMouse ? Colors.primaryText : Colors.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: accountMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: if (accountRow.account) root.switchAccount(accountRow.account.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer row that expands in place into up to four copyable identifiers
|
||||||
|
// (name / DNS name / IPv6 / IPv4) instead of copying a single fixed
|
||||||
|
// field on click.
|
||||||
|
component PeerRow: Column {
|
||||||
|
id: peerRow
|
||||||
|
required property var peer
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
spacing: 2
|
||||||
|
property bool expanded: false
|
||||||
|
|
||||||
|
readonly property var copyOptions: {
|
||||||
|
const opts = []
|
||||||
|
if (peerRow.peer.name) opts.push({ sub: "Name", value: peerRow.peer.name })
|
||||||
|
if (peerRow.peer.dnsName) opts.push({ sub: "DNS name", value: peerRow.peer.dnsName })
|
||||||
|
if (peerRow.peer.ip6) opts.push({ sub: "IPv6", value: peerRow.peer.ip6 })
|
||||||
|
if (peerRow.peer.ip) opts.push({ sub: "IPv4", value: peerRow.peer.ip })
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
radius: 0
|
||||||
|
color: summaryMouse.containsMouse ? Colors.primary
|
||||||
|
: (peerRow.expanded ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
|
||||||
|
implicitHeight: peerContent.implicitHeight + 12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: peerContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 20
|
||||||
|
Layout.minimumWidth: 20
|
||||||
|
Layout.maximumWidth: 20
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
text: root.osIcon(peerRow.peer.os)
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: peerRow.peer.name
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: peerRow.expanded ? "expand_less" : "content_copy"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: summaryMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: summaryMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: peerRow.expanded = !peerRow.expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
visible: peerRow.expanded
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: peerRow.copyOptions
|
||||||
|
delegate: Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
radius: 0
|
||||||
|
color: optionMouse.containsMouse ? Colors.primary : "transparent"
|
||||||
|
implicitHeight: optionRow.implicitHeight + 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: optionRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: 26
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: modelData.value
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: optionMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: modelData.sub
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: optionMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: optionMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
root.copyToClipboard(modelData.value)
|
||||||
|
peerRow.expanded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Panel -------------------------------------------------------------
|
// --- Panel -------------------------------------------------------------
|
||||||
|
|
||||||
PopupWindow {
|
PopupWindow {
|
||||||
@@ -295,8 +795,14 @@ Item {
|
|||||||
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onVisibleChanged: if (visible) root.refresh()
|
||||||
|
|
||||||
anchor {
|
anchor {
|
||||||
window: root.barWindow
|
window: root.barWindow
|
||||||
|
// Unlike anchor.item, anchor.window doesn't default rect to the
|
||||||
|
// window's own bounds — set it explicitly so edges/gravity below
|
||||||
|
// resolve against the full bar (and thus the true screen edges)
|
||||||
|
// instead of a near-zero rect at the window's origin.
|
||||||
rect.x: 0
|
rect.x: 0
|
||||||
rect.y: 0
|
rect.y: 0
|
||||||
rect.width: root.barWindow.width
|
rect.width: root.barWindow.width
|
||||||
@@ -334,6 +840,8 @@ Item {
|
|||||||
TailscaleLogo {
|
TailscaleLogo {
|
||||||
size: 30
|
size: 30
|
||||||
color: label.color
|
color: label.color
|
||||||
|
crossed: label.crossed
|
||||||
|
warning: label.warning
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
@@ -346,13 +854,51 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ToggleSwitch {
|
ToggleSwitch {
|
||||||
checked: root.status.running
|
checked: root.active
|
||||||
onToggled: root.toggleTailscale()
|
onToggled: root.toggleTailscale()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: !root.installed || root.actionStatus !== ""
|
||||||
|
width: parent.width
|
||||||
|
text: !root.installed ? "Tailscale CLI not found on PATH" : root.actionStatus
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other logged-in accounts — only worth a section once
|
||||||
|
// there's actually a choice to make.
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.accounts.length > 1
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Accounts"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.accounts
|
||||||
|
AccountRow {
|
||||||
|
required property var modelData
|
||||||
|
account: modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sign-in prompt — only ever appears if the daemon reports
|
// Sign-in prompt — only ever appears if the daemon reports
|
||||||
// NeedsLogin with a real auth URL to open.
|
// NeedsLogin with a real auth URL to open. Doubles as the
|
||||||
|
// manual fallback if the automatic browser-open above missed.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.status.needsLogin && root.status.authUrl !== ""
|
visible: root.status.needsLogin && root.status.authUrl !== ""
|
||||||
@@ -387,7 +933,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: Qt.openUrlExternally(root.status.authUrl)
|
onClicked: root.openAuthUrl(root.status.authUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,7 +1004,7 @@ Item {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: !root.status.running && !root.status.needsLogin
|
visible: root.installed && !root.status.running && !root.status.needsLogin
|
||||||
text: "Tailscale is off"
|
text: "Tailscale is off"
|
||||||
font.family: Appearance.fontFamily
|
font.family: Appearance.fontFamily
|
||||||
font.pixelSize: Appearance.fontSizeSmall
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
@@ -534,56 +1080,9 @@ Item {
|
|||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.status.peers
|
model: root.status.peers
|
||||||
Rectangle {
|
PeerRow {
|
||||||
id: peerRow
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
width: parent.width
|
peer: modelData
|
||||||
radius: 0
|
|
||||||
color: peerMouse.containsMouse ? Colors.primary : "transparent"
|
|
||||||
implicitHeight: peerContent.implicitHeight + 12
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
id: peerContent
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
anchors.margins: 6
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Text {
|
|
||||||
Layout.preferredWidth: 20
|
|
||||||
Layout.minimumWidth: 20
|
|
||||||
Layout.maximumWidth: 20
|
|
||||||
horizontalAlignment: Text.AlignLeft
|
|
||||||
text: root.osIcon(peerRow.modelData.os)
|
|
||||||
font.family: Appearance.iconFontFamily
|
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
|
||||||
font.pixelSize: 14
|
|
||||||
color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
text: peerRow.modelData.name
|
|
||||||
elide: Text.ElideRight
|
|
||||||
font.family: Appearance.fontFamily
|
|
||||||
font.pixelSize: Appearance.fontSize
|
|
||||||
color: peerMouse.containsMouse ? Colors.primaryText : Colors.foreground
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: peerRow.modelData.ip
|
|
||||||
font.family: Appearance.fontFamily
|
|
||||||
font.pixelSize: Appearance.fontSizeSmall
|
|
||||||
color: peerMouse.containsMouse ? Colors.primaryText : Colors.muted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: peerMouse
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: root.copyToClipboard(peerRow.modelData.ip)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
554
dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml
Normal file
554
dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Io
|
||||||
|
import "../../config"
|
||||||
|
|
||||||
|
// A switcher between commercial VPN providers, distinct from
|
||||||
|
// TailscaleIndicator.qml — Tailscale is a mesh overlay network you generally
|
||||||
|
// leave running; these are traditional single-tunnel VPN clients where only
|
||||||
|
// one makes sense to have active at a time. Connecting one here disconnects
|
||||||
|
// the other first.
|
||||||
|
//
|
||||||
|
// V1 scope: connect (provider's own "fastest"/default) and disconnect per
|
||||||
|
// provider, live status, no region/server picker yet. Provider CLIs:
|
||||||
|
// - Mullvad: official `mullvad` CLI (mullvad-vpn-daemon package), has
|
||||||
|
// real JSON status output.
|
||||||
|
// - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain-
|
||||||
|
// text status only — parsed defensively since its exact wording isn't
|
||||||
|
// guaranteed across versions.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
implicitWidth: label.implicitWidth + 4
|
||||||
|
implicitHeight: Appearance.barHeight
|
||||||
|
|
||||||
|
required property var barWindow
|
||||||
|
|
||||||
|
property int refreshIntervalSec: 20
|
||||||
|
readonly property int _refreshIntervalMs: Math.max(5, Math.min(3600, refreshIntervalSec)) * 1000
|
||||||
|
|
||||||
|
// Whether each CLI is on PATH at all. Starts optimistic so the panel
|
||||||
|
// doesn't flash "not installed" before the first `which` check lands.
|
||||||
|
property bool mullvadInstalled: true
|
||||||
|
property bool protonInstalled: true
|
||||||
|
|
||||||
|
// "disconnected" | "connecting" | "connected" | "disconnecting"
|
||||||
|
property string mullvadState: "disconnected"
|
||||||
|
property string mullvadDetail: ""
|
||||||
|
property string protonState: "disconnected"
|
||||||
|
property string protonDetail: ""
|
||||||
|
|
||||||
|
readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected"
|
||||||
|
|
||||||
|
// Set while switching from one provider to the other: holds which one
|
||||||
|
// to connect once the current one has finished disconnecting.
|
||||||
|
property string pendingConnect: ""
|
||||||
|
|
||||||
|
// Surfaced under the header for a failed connect/disconnect; cleared
|
||||||
|
// automatically after a few seconds.
|
||||||
|
property string actionStatus: ""
|
||||||
|
|
||||||
|
function shortLine(text) {
|
||||||
|
const line = String(text || "").trim().split("\n")[0] || ""
|
||||||
|
return line.length > 80 ? line.substring(0, 77) + "…" : line
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMullvadStatus(text) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
const state = String(data.state || "disconnected")
|
||||||
|
const loc = data.details && data.details.location
|
||||||
|
root.mullvadState = state
|
||||||
|
root.mullvadDetail = loc ? [loc.city, loc.country].filter(Boolean).join(", ") : ""
|
||||||
|
} catch (e) {
|
||||||
|
root.mullvadState = "disconnected"
|
||||||
|
root.mullvadDetail = ""
|
||||||
|
}
|
||||||
|
VpnExclusivity.mullvadConnected = root.mullvadState === "connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
// No --json here — ProtonVPN's CLI only prints plain text, so this
|
||||||
|
// just looks for a "Status: …" line and, best-effort, a following
|
||||||
|
// "Server:"/"Country:" line if the CLI happens to print one.
|
||||||
|
function applyProtonStatus(text) {
|
||||||
|
const body = String(text || "")
|
||||||
|
const statusMatch = body.match(/^Status:\s*(.+)$/mi)
|
||||||
|
const raw = statusMatch ? statusMatch[1].trim().toLowerCase() : ""
|
||||||
|
root.protonState = raw.indexOf("connected") === 0 ? "connected" : "disconnected"
|
||||||
|
const serverMatch = body.match(/^Server:\s*(.+)$/mi)
|
||||||
|
const countryMatch = body.match(/^Country:\s*(.+)$/mi)
|
||||||
|
root.protonDetail = serverMatch ? serverMatch[1].trim() : (countryMatch ? countryMatch[1].trim() : "")
|
||||||
|
VpnExclusivity.protonConnected = root.protonState === "connected"
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshMullvad() {
|
||||||
|
if (!root.mullvadInstalled) {
|
||||||
|
if (!mullvadWhichProc.running) mullvadWhichProc.running = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!mullvadStatusProc.running) {
|
||||||
|
mullvadStatusProc.running = true
|
||||||
|
mullvadPollWatchdog.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProton() {
|
||||||
|
if (!root.protonInstalled) {
|
||||||
|
if (!protonWhichProc.running) protonWhichProc.running = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!protonStatusProc.running) {
|
||||||
|
protonStatusProc.running = true
|
||||||
|
protonPollWatchdog.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
root.refreshMullvad()
|
||||||
|
root.refreshProton()
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectMullvad() {
|
||||||
|
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
||||||
|
if (root.protonState !== "disconnected") {
|
||||||
|
root.pendingConnect = "mullvad"
|
||||||
|
root.disconnectProton()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.pendingConnect = ""
|
||||||
|
mullvadConnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectProton() {
|
||||||
|
if (protonConnectProc.running || protonDisconnectProc.running) return
|
||||||
|
if (root.mullvadState !== "disconnected") {
|
||||||
|
root.pendingConnect = "protonvpn"
|
||||||
|
root.disconnectMullvad()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.pendingConnect = ""
|
||||||
|
protonConnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectMullvad() {
|
||||||
|
if (mullvadDisconnectProc.running) return
|
||||||
|
mullvadDisconnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectProton() {
|
||||||
|
if (protonDisconnectProc.running) return
|
||||||
|
protonDisconnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMullvad() {
|
||||||
|
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
||||||
|
if (root.mullvadState === "connected") root.disconnectMullvad()
|
||||||
|
else root.connectMullvad()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProton() {
|
||||||
|
if (protonConnectProc.running || protonDisconnectProc.running) return
|
||||||
|
if (root.protonState === "connected") root.disconnectProton()
|
||||||
|
else root.connectProton()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tailscale fights Mullvad/ProtonVPN for the default route/DNS, so fire
|
||||||
|
// this right after either one finishes connecting — a harmless no-op
|
||||||
|
// if Tailscale was already down. TailscaleIndicator.qml also backs
|
||||||
|
// itself off reactively (via VpnExclusivity) as a backstop, but doing
|
||||||
|
// it here too avoids waiting out its poll interval.
|
||||||
|
Process {
|
||||||
|
id: tailscaleDownProc
|
||||||
|
command: ["tailscale", "down"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mullvad ---------------------------------------------------------
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadWhichProc
|
||||||
|
command: ["which", "mullvad"]
|
||||||
|
running: true
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.mullvadInstalled = exitCode === 0
|
||||||
|
if (root.mullvadInstalled) root.refreshMullvad()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadStatusProc
|
||||||
|
command: ["mullvad", "status", "--json"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: mullvadStatusCollector
|
||||||
|
onStreamFinished: root.applyMullvadStatus(mullvadStatusCollector.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0 && !mullvadWhichProc.running) mullvadWhichProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadConnectProc
|
||||||
|
command: ["mullvad", "connect", "--wait"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: mullvadConnectErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed")
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
} else if (!tailscaleDownProc.running) {
|
||||||
|
tailscaleDownProc.running = true
|
||||||
|
}
|
||||||
|
root.refreshMullvad()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: mullvadDisconnectProc
|
||||||
|
command: ["mullvad", "disconnect", "--wait"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: mullvadDisconnectErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed")
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
root.refreshMullvad()
|
||||||
|
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||||
|
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: mullvadPollWatchdog
|
||||||
|
interval: 15000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ProtonVPN ---------------------------------------------------------
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: protonWhichProc
|
||||||
|
command: ["which", "protonvpn"]
|
||||||
|
running: true
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.protonInstalled = exitCode === 0
|
||||||
|
if (root.protonInstalled) root.refreshProton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: protonStatusProc
|
||||||
|
command: ["protonvpn", "status"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: protonStatusCollector
|
||||||
|
onStreamFinished: root.applyProtonStatus(protonStatusCollector.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0 && !protonWhichProc.running) protonWhichProc.running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: protonConnectProc
|
||||||
|
command: ["protonvpn", "connect"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: protonConnectErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed")
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
} else if (!tailscaleDownProc.running) {
|
||||||
|
tailscaleDownProc.running = true
|
||||||
|
}
|
||||||
|
root.refreshProton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: protonDisconnectProc
|
||||||
|
command: ["protonvpn", "disconnect"]
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
stderr: StdioCollector { id: protonDisconnectErr }
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed")
|
||||||
|
actionStatusTimer.restart()
|
||||||
|
}
|
||||||
|
root.refreshProton()
|
||||||
|
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||||
|
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: protonPollWatchdog
|
||||||
|
interval: 15000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: if (protonStatusProc.running) protonStatusProc.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared timers -----------------------------------------------------
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshTimer
|
||||||
|
interval: root._refreshIntervalMs
|
||||||
|
repeat: true
|
||||||
|
running: true
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: actionStatusTimer
|
||||||
|
interval: 4000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.actionStatus = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bar icon ------------------------------------------------------------
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: label
|
||||||
|
anchors.centerIn: parent
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: Appearance.iconSize
|
||||||
|
color: root.anyConnected ? Colors.primary : Colors.muted
|
||||||
|
text: "vpn_lock"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
panel.visible = !panel.visible
|
||||||
|
grab.active = panel.visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reusable pieces -----------------------------------------------------
|
||||||
|
|
||||||
|
component ToggleSwitch: Item {
|
||||||
|
id: toggleRoot
|
||||||
|
property bool checked: false
|
||||||
|
property bool busy: false
|
||||||
|
signal toggled()
|
||||||
|
implicitWidth: 34
|
||||||
|
implicitHeight: 18
|
||||||
|
opacity: toggleRoot.enabled ? 1 : 0.4
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||||
|
opacity: (toggleRoot.checked ? 1 : 0.35) * (toggleRoot.busy ? 0.6 : 1)
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: parent.height - 4
|
||||||
|
height: parent.height - 4
|
||||||
|
radius: 0
|
||||||
|
color: Colors.background
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: toggleRoot.checked ? parent.width - width - 2 : 2
|
||||||
|
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !toggleRoot.busy
|
||||||
|
cursorShape: toggleRoot.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||||
|
onClicked: toggleRoot.toggled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single provider's row — icon, name, live status line, and a toggle
|
||||||
|
// that connects/disconnects it (routed back through root so it can
|
||||||
|
// enforce the one-provider-at-a-time rule).
|
||||||
|
component ProviderRow: Rectangle {
|
||||||
|
id: provRow
|
||||||
|
property string label: ""
|
||||||
|
property string subIcon: "shield"
|
||||||
|
property string detailText: ""
|
||||||
|
property string state: "disconnected"
|
||||||
|
property bool installed: true
|
||||||
|
signal toggle()
|
||||||
|
width: parent ? parent.width : 0
|
||||||
|
radius: 0
|
||||||
|
implicitHeight: rowContent.implicitHeight + 12
|
||||||
|
color: provRow.state === "connected"
|
||||||
|
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
||||||
|
: "transparent"
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: rowContent
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.preferredWidth: 20
|
||||||
|
Layout.minimumWidth: 20
|
||||||
|
Layout.maximumWidth: 20
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: provRow.subIcon
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 14
|
||||||
|
color: provRow.state === "connected" ? Colors.primary : Colors.foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: provRow.label
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSize
|
||||||
|
font.bold: provRow.state === "connected"
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: !provRow.installed ? "Not installed"
|
||||||
|
: provRow.state === "connecting" ? "Connecting…"
|
||||||
|
: provRow.state === "disconnecting" ? "Disconnecting…"
|
||||||
|
: provRow.state === "connected" ? (provRow.detailText || "Connected")
|
||||||
|
: "Disconnected"
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleSwitch {
|
||||||
|
enabled: provRow.installed
|
||||||
|
checked: provRow.state === "connected" || provRow.state === "connecting"
|
||||||
|
busy: provRow.state === "connecting" || provRow.state === "disconnecting"
|
||||||
|
onToggled: provRow.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Panel -----------------------------------------------------------
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: panel
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
readonly property int contentWidth: 244
|
||||||
|
readonly property int contentPadding: 16
|
||||||
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
onVisibleChanged: if (visible) root.refresh()
|
||||||
|
|
||||||
|
anchor {
|
||||||
|
window: root.barWindow
|
||||||
|
rect.x: 0
|
||||||
|
rect.y: 0
|
||||||
|
rect.width: root.barWindow.width
|
||||||
|
rect.height: root.barWindow.height
|
||||||
|
edges: Edges.Bottom | Edges.Right
|
||||||
|
gravity: Edges.Bottom | Edges.Left
|
||||||
|
margins.bottom: -4
|
||||||
|
margins.right: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HyprlandFocusGrab {
|
||||||
|
id: grab
|
||||||
|
windows: [panel]
|
||||||
|
onCleared: panel.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 0
|
||||||
|
color: Colors.surface
|
||||||
|
border.color: Colors.muted
|
||||||
|
border.width: 3
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: mainColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: panel.contentPadding
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "vpn_lock"
|
||||||
|
font.family: Appearance.iconFontFamily
|
||||||
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
|
font.pixelSize: 22
|
||||||
|
color: root.anyConnected ? Colors.primary : Colors.foreground
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "VPN"
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: 18
|
||||||
|
font.bold: true
|
||||||
|
color: Colors.foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: root.actionStatus !== ""
|
||||||
|
width: parent.width
|
||||||
|
text: root.actionStatus
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
ProviderRow {
|
||||||
|
label: "Mullvad"
|
||||||
|
subIcon: "shield"
|
||||||
|
state: root.mullvadState
|
||||||
|
detailText: root.mullvadDetail
|
||||||
|
installed: root.mullvadInstalled
|
||||||
|
onToggle: root.toggleMullvad()
|
||||||
|
}
|
||||||
|
ProviderRow {
|
||||||
|
label: "ProtonVPN"
|
||||||
|
subIcon: "gpp_good"
|
||||||
|
state: root.protonState
|
||||||
|
detailText: root.protonDetail
|
||||||
|
installed: root.protonInstalled
|
||||||
|
onToggle: root.toggleProton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Connecting one disconnects the other — only one VPN tunnel at a time."
|
||||||
|
font.family: Appearance.fontFamily
|
||||||
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
|
color: Colors.muted
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user