7 Commits

Author SHA1 Message Date
9df5731ee1 changed volume location and widget colours 2026-08-25 09:35:22 +01:00
3806935316 implimented multiple VPN connectors 2026-08-25 09:34:55 +01:00
516f8eed82 removed tray 2026-08-25 09:34:19 +01:00
369179c057 added vpn exclusitivity to allow functions to coexist 2026-08-25 09:34:04 +01:00
322804577f added vpn exclusitivity to allow functions to coexist 2026-08-25 09:33:45 +01:00
0eee14858b basic tailscale widget 2026-08-25 07:25:38 +01:00
461f4c3fff added tailscale widget 2026-08-25 07:25:27 +01:00
6 changed files with 1728 additions and 13 deletions

View File

@@ -0,0 +1,16 @@
pragma Singleton
import QtQuick
// Tailscale, Mullvad, and ProtonVPN all fight over the default route/DNS if
// more than one is active at once (see VpnIndicator.qml's header comment) —
// only one of the three should ever actually be connected. This singleton
// is the shared signal between the two widget files: VpnIndicator.qml
// writes Mullvad/ProtonVPN's live connection state here as it polls them;
// TailscaleIndicator.qml reads it to back off (`tailscale down`) rather
// than stay up alongside either one, and to refuse turning Tailscale back
// on while one is still connected.
QtObject {
property bool mullvadConnected: false
property bool protonConnected: false
readonly property bool commercialVpnActive: mullvadConnected || protonConnected
}

View File

@@ -1,3 +1,4 @@
module config module config
singleton Colors 1.0 Colors.qml singleton Colors 1.0 Colors.qml
singleton Appearance 1.0 Appearance.qml singleton Appearance 1.0 Appearance.qml
singleton VpnExclusivity 1.0 VpnExclusivity.qml

View File

@@ -46,7 +46,9 @@ PanelWindow {
rightMargin: Appearance.spacing rightMargin: Appearance.spacing
} }
spacing: Appearance.spacing spacing: Appearance.spacing
Tray {} //Tray {}
TailscaleIndicator { barWindow: bar }
VpnIndicator { barWindow: bar }
NetworkIndicator { barWindow: bar } NetworkIndicator { barWindow: bar }
BluetoothIndicator { barWindow: bar } BluetoothIndicator { barWindow: bar }
Volume { barWindow: bar } Volume { barWindow: bar }

File diff suppressed because it is too large Load Diff

View 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
}
}
}
}
}

View File

@@ -11,10 +11,15 @@ PanelWindow {
color: "transparent" color: "transparent"
exclusiveZone: 0 exclusiveZone: 0
anchors { bottom: true } // Top-right, just under the bar — same fixed distance from the bar and
margins.bottom: 70 // screen edge (Appearance.spacing) that every popup opened from the bar
// itself uses, so this lines up with them instead of floating
// bottom-center.
anchors { top: true; right: true }
margins.top: Appearance.spacing
margins.right: Appearance.spacing
implicitWidth: 240 implicitWidth: 272
implicitHeight: 54 implicitHeight: 54
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
@@ -59,19 +64,61 @@ PanelWindow {
} }
} }
Rectangle { Item {
id: slider
Layout.fillWidth: true Layout.fillWidth: true
height: 6 implicitHeight: 16
radius: 3
color: Colors.muted readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0
readonly property bool enabled: !!osd.sink
Rectangle { Rectangle {
height: parent.height id: track
radius: 3 anchors.verticalCenter: parent.verticalCenter
color: Colors.primary width: parent.width
width: parent.width * (osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0) height: 4
Behavior on width { NumberAnimation { duration: Appearance.animationDuration } } radius: 0
color: Colors.muted
opacity: 0.35
} }
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: track.width * Math.max(0, Math.min(1, slider.value))
height: 4
radius: 0
color: Colors.primary
opacity: slider.enabled ? 1 : 0.4
}
Rectangle {
width: 12
height: 12
radius: 6
color: Colors.primary
opacity: slider.enabled ? 1 : 0.4
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(track.width - width, track.width * slider.value - width / 2))
}
MouseArea {
anchors.fill: parent
enabled: slider.enabled
cursorShape: Qt.PointingHandCursor
function updateFromX(mx) {
if (!osd.sink || !osd.sink.audio) return
osd.sink.audio.volume = Math.max(0, Math.min(1, mx / track.width))
}
onPressed: (mouse) => updateFromX(mouse.x)
onPositionChanged: (mouse) => { if (pressed) updateFromX(mouse.x) }
}
}
Text {
Layout.preferredWidth: 32
horizontalAlignment: Text.AlignRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
text: Math.round(slider.value * 100) + "%"
} }
} }
} }