Skip to content

Commit 0ff58c4

Browse files
committed
feat(mullvad): add Mullvad VPN plugin
Bar widget + panel + settings to control Mullvad VPN through the `mullvad` CLI. Replaces the official Mullvad GUI on Linux desktops. Features: - Bar widget: state-driven shield icon (green=connected, yellow= connecting, red=lockdown blocking traffic, grey=disconnected) - Click opens panel (configurable: toggle vs panel) - Right-click context menu (Connect/Disconnect, Open panel, Settings) - Tooltip via TooltipService showing relay + IP when connected - Panel header: state, current relay constraint, connect/disconnect button, account-expiry banner when fewer than N days remain - Quick toggles (round-trip with the CLI): - Lockdown mode (kill switch) - Auto-connect - LAN sharing - Search-first relay picker (~700 relays). Country rows by default, expand on search. Click a row to set the relay constraint and optionally connect immediately. - Advanced (collapsed): multihop on/off + entry country, IP version - Settings UI exposes 10 knobs (refresh interval, click action, click-connects, lockdown confirm, expiry warning days, etc.) - IPC handlers at `plugin:mullvad`: toggle / connect / disconnect / reconnect / togglePanel / refresh / status / setLocation / setLockdown - i18n: English + German Disclosure: This plugin was written entirely by an LLM (Claude). The maintainer of the fork (@OCSPG) does not have prior QML experience, so structural review of the QML is appreciated. The plugin was tested manually on Noctalia Shell 4.7.6 with mullvad-cli 2026.1; toggling, relay selection, multihop, and quick toggles all round-trip with the CLI. Recommended install is the daemon-only package (mullvad-vpn-daemon) since the plugin replaces the GUI.
1 parent 34ccdd8 commit 0ff58c4

10 files changed

Lines changed: 1627 additions & 0 deletions

File tree

mullvad/BarWidget.qml

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import QtQuick
2+
import QtQuick.Controls
3+
import QtQuick.Layouts
4+
import Quickshell
5+
import qs.Commons
6+
import qs.Widgets
7+
import qs.Services.UI
8+
9+
Item {
10+
id: root
11+
12+
property var pluginApi: null
13+
property ShellScreen screen
14+
property string widgetId: ""
15+
property string section: ""
16+
property int sectionWidgetIndex: -1
17+
property int sectionWidgetsCount: 0
18+
19+
readonly property string screenName: screen ? screen.name : ""
20+
readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
21+
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
22+
23+
readonly property var main: pluginApi?.mainInstance
24+
readonly property string vpnState: main?.state ?? "disconnected"
25+
readonly property bool locked: main?.locked ?? false
26+
readonly property bool installed: main?.installed ?? false
27+
28+
readonly property color stateColor: {
29+
if (!installed) return Color.mError
30+
if (vpnState === "connected") return Color.mPrimary
31+
if (vpnState === "connecting" || vpnState === "disconnecting") return Color.mTertiary
32+
if (locked) return Color.mError
33+
if (vpnState === "error") return Color.mError
34+
return Color.mOnSurface
35+
}
36+
37+
property real margins: Style.marginM * 2
38+
39+
readonly property real contentWidth: isVertical ? Style.capsuleHeight : Math.round(layout.implicitWidth + margins)
40+
readonly property real contentHeight: isVertical ? Math.round(layout.implicitHeight + margins) : Style.capsuleHeight
41+
42+
implicitWidth: contentWidth
43+
implicitHeight: contentHeight
44+
45+
Layout.alignment: Qt.AlignVCenter
46+
47+
Rectangle {
48+
id: visualCapsule
49+
x: Style.pixelAlignCenter(parent.width, width)
50+
y: Style.pixelAlignCenter(parent.height, height)
51+
width: root.contentWidth
52+
height: root.contentHeight
53+
radius: Style.radiusM
54+
color: Style.capsuleColor
55+
border.color: Style.capsuleBorderColor
56+
border.width: Style.capsuleBorderWidth
57+
58+
Item {
59+
id: layout
60+
anchors.verticalCenter: parent.verticalCenter
61+
anchors.horizontalCenter: parent.horizontalCenter
62+
implicitWidth: iconRow.implicitWidth
63+
implicitHeight: iconRow.implicitHeight
64+
65+
RowLayout {
66+
id: iconRow
67+
spacing: Style.marginXS
68+
69+
NIcon {
70+
icon: root.installed ? "shield" : "shield-off"
71+
color: root.stateColor
72+
}
73+
74+
NText {
75+
visible: !root.isVertical && (root.main?.showCityName ?? false) && root.vpnState === "connected"
76+
text: root.main?.currentLocation?.city || ""
77+
pointSize: Style.fontSizeXS
78+
color: Color.mOnSurface
79+
}
80+
81+
NText {
82+
visible: !root.isVertical && (root.main?.showIp ?? false) && root.vpnState === "connected" && (root.main?.currentLocation?.ipv4 || "").length > 0
83+
text: root.main?.currentLocation?.ipv4 || ""
84+
pointSize: Style.fontSizeXS
85+
font.family: Settings.data.ui.fontFixed
86+
color: Color.mOnSurface
87+
}
88+
}
89+
}
90+
}
91+
92+
NPopupContextMenu {
93+
id: contextMenu
94+
model: [
95+
{
96+
"label": pluginApi?.tr(root.vpnState === "connected" ? "context.disconnect" : "context.connect"),
97+
"action": "toggle",
98+
"icon": root.vpnState === "connected" ? "shield-off" : "shield",
99+
"enabled": root.installed
100+
},
101+
{
102+
"label": pluginApi?.tr("context.open-panel"),
103+
"action": "panel",
104+
"icon": "menu"
105+
},
106+
{
107+
"label": pluginApi?.tr("actions.widget-settings"),
108+
"action": "widget-settings",
109+
"icon": "settings"
110+
}
111+
]
112+
onTriggered: action => {
113+
contextMenu.close()
114+
PanelService.closeContextMenu(screen)
115+
if (action === "toggle") root.main?.toggleVpn()
116+
else if (action === "panel") pluginApi?.openPanel(screen, root)
117+
else if (action === "widget-settings") BarService.openPluginSettings(screen, pluginApi.manifest)
118+
}
119+
}
120+
121+
function _tooltipText() {
122+
if (!root.installed) return pluginApi?.tr("state.not-installed") || ""
123+
if (root.vpnState === "error") return pluginApi?.tr("state.error") || ""
124+
if (root.vpnState === "connected" && root.main?.currentLocation) {
125+
var loc = root.main.currentLocation
126+
return pluginApi?.tr("state.connected") + ": " +
127+
(loc.city || loc.country) +
128+
(loc.hostname ? "\n" + loc.hostname : "") +
129+
(loc.ipv4 ? "\n" + loc.ipv4 : "")
130+
}
131+
if (root.locked) return pluginApi?.tr("state.blocked") || ""
132+
return pluginApi?.tr("state.disconnected") || ""
133+
}
134+
135+
MouseArea {
136+
id: mouseArea
137+
anchors.fill: parent
138+
hoverEnabled: true
139+
cursorShape: Qt.PointingHandCursor
140+
acceptedButtons: Qt.LeftButton | Qt.RightButton
141+
142+
onEntered: TooltipService.show(root, root._tooltipText(), BarService.getTooltipDirection(root.screen?.name))
143+
onExited: TooltipService.hide()
144+
145+
onClicked: (mouse) => {
146+
if (mouse.button === Qt.LeftButton) {
147+
if ((root.main?.clickAction || "toggle") === "toggle") {
148+
root.main?.toggleVpn()
149+
} else {
150+
pluginApi?.openPanel(root.screen, root)
151+
}
152+
} else if (mouse.button === Qt.RightButton) {
153+
PanelService.showContextMenu(contextMenu, root, screen)
154+
}
155+
}
156+
}
157+
}

0 commit comments

Comments
 (0)