555 lines
19 KiB
QML
555 lines
19 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|