feat: VPN fixes & improvements (#1116)

* feat: add VPN settings and management UI

- Add VPN configuration UI
- Update VPN toggle visibility to check enabled providers

* controlcenter: VPN modal transitions & cleanup

* controlcenter: VPN modal styling

* controlcenter: VPN modal scrim

* controlcenter: VPN modal padding

* controlcenter: VPN modal enter & exit behaviour

* vpn: reworked managment & fixes

       - Switched to primarily use provider status json
         - Emitting more detailed toasts and errors/states
       - Authentication prompt button to open in browser
       - Better resets and provider change

* vpn: replace use of qt5compat

* fixing providers, status and adding custom option

* local qml-lint script lied to me

* format

* section order

* linting

---------

Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
This commit is contained in:
Robin Seger 2026-03-29 06:25:52 +02:00 committed by GitHub
parent c588794b20
commit 5c59e4490a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 624 additions and 38 deletions

View file

@ -320,6 +320,24 @@ Singleton {
} }
function serializeUtilities(): var { function serializeUtilities(): var {
const vpnProviders = [];
for (let i = 0; i < utilities.vpn.provider.length; i++) {
const p = utilities.vpn.provider[i];
const provider = {
displayName: p.displayName,
enabled: p.enabled,
iface: p.iface,
name: p.name
};
if (p.connectCmd && p.connectCmd.length > 0) {
provider.connectCmd = p.connectCmd;
}
if (p.disconnectCmd && p.disconnectCmd.length > 0) {
provider.disconnectCmd = p.disconnectCmd;
}
vpnProviders.push(provider);
}
return { return {
enabled: utilities.enabled, enabled: utilities.enabled,
maxToasts: utilities.maxToasts, maxToasts: utilities.maxToasts,
@ -339,7 +357,7 @@ Singleton {
}, },
vpn: { vpn: {
enabled: utilities.vpn.enabled, enabled: utilities.vpn.enabled,
provider: utilities.vpn.provider provider: vpnProviders
}, },
quickToggles: utilities.quickToggles quickToggles: utilities.quickToggles
}; };

View file

@ -62,6 +62,6 @@ JsonObject {
component Vpn: JsonObject { component Vpn: JsonObject {
property bool enabled: false property bool enabled: false
property list<var> provider: ["netbird"] property list<var> provider: []
} }
} }

View file

@ -109,10 +109,13 @@ DeviceDetails {
inactiveOnColour: Colours.palette.m3onSecondaryContainer inactiveOnColour: Colours.palette.m3onSecondaryContainer
onClicked: { onClicked: {
const provider = Config.utilities.vpn.provider[root.vpnProvider.index];
editVpnDialog.editIndex = root.vpnProvider.index; editVpnDialog.editIndex = root.vpnProvider.index;
editVpnDialog.providerName = root.vpnProvider.name; editVpnDialog.providerName = root.vpnProvider.name;
editVpnDialog.displayName = root.vpnProvider.displayName; editVpnDialog.displayName = root.vpnProvider.displayName;
editVpnDialog.interfaceName = root.vpnProvider.interface; editVpnDialog.interfaceName = root.vpnProvider.interface;
editVpnDialog.connectCmd = (provider && provider.connectCmd) ? provider.connectCmd.join(" ") : "";
editVpnDialog.disconnectCmd = (provider && provider.disconnectCmd) ? provider.disconnectCmd.join(" ") : "";
editVpnDialog.open(); editVpnDialog.open();
} }
} }
@ -136,6 +139,30 @@ DeviceDetails {
} }
} }
} }
TextButton {
Layout.fillWidth: true
Layout.topMargin: Appearance.spacing.normal
visible: root.providerEnabled && VPN.status.state === "needs-auth" && VPN.status.authUrl !== ""
text: qsTr("Open Login Page")
inactiveColour: Colours.palette.m3tertiaryContainer
inactiveOnColour: Colours.palette.m3onTertiaryContainer
onClicked: {
Qt.openUrlExternally(VPN.status.authUrl);
}
}
StyledText {
Layout.fillWidth: true
Layout.topMargin: Appearance.spacing.normal
visible: root.providerEnabled && VPN.status.state === "needs-auth" && VPN.status.authUrl === ""
text: qsTr("Click 'Connect' to generate authentication URL")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
} }
} }
}, },
@ -176,12 +203,31 @@ DeviceDetails {
return qsTr("Disabled"); return qsTr("Disabled");
if (VPN.connecting) if (VPN.connecting)
return qsTr("Connecting..."); return qsTr("Connecting...");
if (VPN.connected)
switch (VPN.status.state) {
case "connected":
return qsTr("Connected"); return qsTr("Connected");
return qsTr("Enabled (Not connected)"); case "disconnected":
return qsTr("Disconnected");
case "connecting":
return qsTr("Connecting...");
case "needs-auth":
return qsTr("Authentication required");
case "error":
return qsTr("Error");
default:
return qsTr("Unknown");
}
} }
} }
PropertyRow {
visible: VPN.status.reason !== ""
showTopMargin: true
label: qsTr("Details")
value: VPN.status.reason
}
PropertyRow { PropertyRow {
showTopMargin: true showTopMargin: true
label: qsTr("Enabled") label: qsTr("Enabled")
@ -200,6 +246,8 @@ DeviceDetails {
property string providerName: "" property string providerName: ""
property string displayName: "" property string displayName: ""
property string interfaceName: "" property string interfaceName: ""
property string connectCmd: ""
property string disconnectCmd: ""
function closeWithAnimation(): void { function closeWithAnimation(): void {
close(); close();
@ -349,6 +397,82 @@ DeviceDetails {
} }
} }
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller / 2
visible: editVpnDialog.connectCmd.length > 0
StyledText {
text: qsTr("Connect Command")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: connectCmdFieldEdit.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: connectCmdFieldEdit.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: connectCmdFieldEdit
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: editVpnDialog.connectCmd
onTextChanged: editVpnDialog.connectCmd = text
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller / 2
visible: editVpnDialog.disconnectCmd.length > 0
StyledText {
text: qsTr("Disconnect Command")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: disconnectCmdFieldEdit.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: disconnectCmdFieldEdit.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: disconnectCmdFieldEdit
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: editVpnDialog.disconnectCmd
onTextChanged: editVpnDialog.disconnectCmd = text
}
}
}
RowLayout { RowLayout {
Layout.topMargin: Appearance.spacing.normal Layout.topMargin: Appearance.spacing.normal
Layout.fillWidth: true Layout.fillWidth: true
@ -376,12 +500,23 @@ DeviceDetails {
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
if (i === editVpnDialog.editIndex) { if (i === editVpnDialog.editIndex) {
providers.push({ const hasCommands = editVpnDialog.connectCmd.length > 0 && editVpnDialog.disconnectCmd.length > 0;
name: editVpnDialog.providerName, const newProvider = {
displayName: editVpnDialog.displayName || editVpnDialog.interfaceName, displayName: editVpnDialog.displayName || editVpnDialog.interfaceName,
interface: editVpnDialog.interfaceName, enabled: wasEnabled,
enabled: wasEnabled iface: editVpnDialog.interfaceName,
}); name: editVpnDialog.providerName,
connectCmd: hasCommands ? editVpnDialog.connectCmd.split(" ").filter(s => s.length > 0) : undefined,
disconnectCmd: hasCommands ? editVpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0) : undefined
};
// Remove undefined properties
if (!hasCommands) {
delete newProvider.connectCmd;
delete newProvider.disconnectCmd;
}
providers.push(newProvider);
} else { } else {
providers.push(Config.utilities.vpn.provider[i]); providers.push(Config.utilities.vpn.provider[i]);
} }

View file

@ -159,15 +159,38 @@ ColumnLayout {
StyledText { StyledText {
Layout.fillWidth: true Layout.fillWidth: true
text: { text: {
if (modelData.enabled && VPN.connected) if (!modelData.enabled)
return qsTr("Connected"); return qsTr("Disabled");
if (modelData.enabled && VPN.connecting)
if (VPN.connecting)
return qsTr("Connecting..."); return qsTr("Connecting...");
if (modelData.enabled)
switch (VPN.status.state) {
case "connected":
return qsTr("Connected");
case "disconnected":
return qsTr("Enabled"); return qsTr("Enabled");
return qsTr("Disabled"); case "connecting":
return qsTr("Connecting...");
case "needs-auth":
return qsTr("Auth required");
case "error":
return qsTr("Error");
default:
return qsTr("Enabled");
}
}
color: {
if (!modelData.enabled)
return Colours.palette.m3outline;
if (VPN.status.state === "connected")
return Colours.palette.m3primary;
if (VPN.status.state === "error")
return Colours.palette.m3error;
if (VPN.status.state === "needs-auth")
return Colours.palette.m3tertiary;
return Colours.palette.m3onSurface;
} }
color: modelData.enabled ? (VPN.connected ? Colours.palette.m3primary : Colours.palette.m3onSurface) : Colours.palette.m3outline
font.pointSize: Appearance.font.size.small font.pointSize: Appearance.font.size.small
font.weight: modelData.enabled && VPN.connected ? 500 : 400 font.weight: modelData.enabled && VPN.connected ? 500 : 400
elide: Text.ElideRight elide: Text.ElideRight
@ -271,6 +294,8 @@ ColumnLayout {
property string providerName: "" property string providerName: ""
property string displayName: "" property string displayName: ""
property string interfaceName: "" property string interfaceName: ""
property string connectCmd: ""
property string disconnectCmd: ""
function showProviderSelection(): void { function showProviderSelection(): void {
currentState = "selection"; currentState = "selection";
@ -286,6 +311,8 @@ ColumnLayout {
providerName = providerType; providerName = providerType;
displayName = defaultDisplayName; displayName = defaultDisplayName;
interfaceName = ""; interfaceName = "";
connectCmd = "";
disconnectCmd = "";
if (currentState === "selection") { if (currentState === "selection") {
transitionToForm.start(); transitionToForm.start();
@ -304,6 +331,8 @@ ColumnLayout {
providerName = isObject ? (provider.name || "custom") : String(provider); providerName = isObject ? (provider.name || "custom") : String(provider);
displayName = isObject ? (provider.displayName || providerName) : providerName; displayName = isObject ? (provider.displayName || providerName) : providerName;
interfaceName = isObject ? (provider.interface || "") : ""; interfaceName = isObject ? (provider.interface || "") : "";
connectCmd = isObject && provider.connectCmd ? provider.connectCmd.join(" ") : "";
disconnectCmd = isObject && provider.disconnectCmd ? provider.disconnectCmd.join(" ") : "";
currentState = "form"; currentState = "form";
open(); open();
@ -491,7 +520,7 @@ ColumnLayout {
TextButton { TextButton {
Layout.fillWidth: true Layout.fillWidth: true
text: qsTr("WireGuard (Custom)") text: qsTr("WireGuard")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface inactiveOnColour: Colours.palette.m3onSurface
onClicked: { onClicked: {
@ -499,6 +528,16 @@ ColumnLayout {
} }
} }
TextButton {
Layout.fillWidth: true
text: qsTr("Custom")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: {
vpnDialog.showAddForm("custom", "Custom VPN");
}
}
TextButton { TextButton {
Layout.topMargin: Appearance.spacing.normal Layout.topMargin: Appearance.spacing.normal
Layout.fillWidth: true Layout.fillWidth: true
@ -604,6 +643,82 @@ ColumnLayout {
} }
} }
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller / 2
visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom")
StyledText {
text: qsTr("Connect Command (e.g., wg-quick up wg0)")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: connectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: connectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: connectCmdField
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: vpnDialog.connectCmd
onTextChanged: vpnDialog.connectCmd = text
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Appearance.spacing.smaller / 2
visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom")
StyledText {
text: qsTr("Disconnect Command (e.g., wg-quick down wg0)")
font.pointSize: Appearance.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: disconnectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Appearance.rounding.small
border.width: 1
border.color: disconnectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: disconnectCmdField
anchors.centerIn: parent
width: parent.width - Appearance.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: vpnDialog.disconnectCmd
onTextChanged: vpnDialog.disconnectCmd = text
}
}
}
RowLayout { RowLayout {
Layout.topMargin: Appearance.spacing.normal Layout.topMargin: Appearance.spacing.normal
Layout.fillWidth: true Layout.fillWidth: true
@ -620,19 +735,37 @@ ColumnLayout {
TextButton { TextButton {
Layout.fillWidth: true Layout.fillWidth: true
text: qsTr("Save") text: qsTr("Save")
enabled: vpnDialog.interfaceName.length > 0 enabled: {
const hasCommands = vpnDialog.connectCmd.length > 0 || vpnDialog.disconnectCmd.length > 0;
if (hasCommands) {
return vpnDialog.interfaceName.length > 0 && vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0;
}
return vpnDialog.interfaceName.length > 0;
}
inactiveColour: Colours.palette.m3primaryContainer inactiveColour: Colours.palette.m3primaryContainer
inactiveOnColour: Colours.palette.m3onPrimaryContainer inactiveOnColour: Colours.palette.m3onPrimaryContainer
onClicked: { onClicked: {
const providers = []; const providers = [];
const hasCommands = vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0;
const newProvider = { const newProvider = {
name: vpnDialog.providerName,
displayName: vpnDialog.displayName || vpnDialog.interfaceName, displayName: vpnDialog.displayName || vpnDialog.interfaceName,
interface: vpnDialog.interfaceName enabled: false,
interface: vpnDialog.interfaceName,
name: vpnDialog.providerName
}; };
if (hasCommands) {
newProvider.connectCmd = vpnDialog.connectCmd.split(" ").filter(s => s.length > 0);
newProvider.disconnectCmd = vpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0);
}
if (vpnDialog.editIndex >= 0) { if (vpnDialog.editIndex >= 0) {
const oldProvider = Config.utilities.vpn.provider[vpnDialog.editIndex];
if (typeof oldProvider === "object" && oldProvider.enabled !== undefined) {
newProvider.enabled = oldProvider.enabled;
}
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) { for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
if (i === vpnDialog.editIndex) { if (i === vpnDialog.editIndex) {
providers.push(newProvider); providers.push(newProvider);

View file

@ -141,8 +141,10 @@ StyledRect {
roleValue: "vpn" roleValue: "vpn"
delegate: Toggle { delegate: Toggle {
icon: "vpn_key" icon: "vpn_key"
checked: VPN.connected checked: VPN.connected && VPN.status.state !== "needs-auth" && VPN.status.state !== "error"
enabled: !VPN.connecting enabled: !VPN.connecting
toggle: VPN.status.state !== "needs-auth" && VPN.status.state !== "error"
inactiveOnColour: Colours.palette.m3onSurfaceVariant
onClicked: VPN.toggle() onClicked: VPN.toggle()
} }
} }

View file

@ -10,6 +10,12 @@ Singleton {
id: root id: root
property bool connected: false property bool connected: false
property var status: ({
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
})
readonly property bool connecting: connectProc.running || disconnectProc.running readonly property bool connecting: connectProc.running || disconnectProc.running
readonly property bool enabled: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false) readonly property bool enabled: Config.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false)
@ -19,7 +25,7 @@ Singleton {
} }
readonly property bool isCustomProvider: typeof providerInput === "object" readonly property bool isCustomProvider: typeof providerInput === "object"
readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput) readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput)
readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : "" readonly property string interfaceName: isCustomProvider ? (providerInput.iface || "") : ""
readonly property var currentConfig: { readonly property var currentConfig: {
const name = providerName; const name = providerName;
const iface = interfaceName; const iface = interfaceName;
@ -30,7 +36,7 @@ Singleton {
return { return {
connectCmd: custom.connectCmd || defaults.connectCmd, connectCmd: custom.connectCmd || defaults.connectCmd,
disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd, disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd,
interface: custom.interface || defaults.interface, interface: custom.iface || defaults.interface,
displayName: custom.displayName || defaults.displayName displayName: custom.displayName || defaults.displayName
}; };
} }
@ -53,7 +59,7 @@ Singleton {
displayName: "Warp" displayName: "Warp"
}, },
"netbird": { "netbird": {
connectCmd: ["netbird", "up"], connectCmd: ["netbird", "up", "--no-browser"],
disconnectCmd: ["netbird", "down"], disconnectCmd: ["netbird", "down"],
interface: "wt0", interface: "wt0",
displayName: "NetBird" displayName: "NetBird"
@ -75,6 +81,10 @@ Singleton {
} }
function connect(): void { function connect(): void {
if (status.state === "needs-auth" && status.authUrl) {
emitStatusToast(status);
return;
}
if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) { if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) {
connectProc.exec(root.currentConfig.connectCmd); connectProc.exec(root.currentConfig.connectCmd);
} }
@ -87,11 +97,7 @@ Singleton {
} }
function toggle(): void { function toggle(): void {
if (connected) { connected ? disconnect() : connect();
disconnect();
} else {
connect();
}
} }
function checkStatus(): void { function checkStatus(): void {
@ -100,18 +106,223 @@ Singleton {
} }
} }
onConnectedChanged: { function getStatusCommand(): var {
switch (providerName) {
case "tailscale":
return ["tailscale", "status", "--json"];
case "netbird":
return ["netbird", "status", "--json"];
case "warp":
return ["warp-cli", "status"];
case "wireguard":
return ["ip", "link", "show"];
default:
return ["ip", "link", "show"];
}
}
function parseTailscaleStatus(output: string): var {
const status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
// Handle empty or whitespace-only output
if (!output || output.trim().length === 0) {
return status;
}
// Check for common non-JSON states first
if (output.includes("Logged out") || output.includes("Stopped") || output.includes("not running") || output.includes("Tailscale is not running")) {
status.state = "disconnected";
return status;
}
// Try to parse as JSON
try {
const data = JSON.parse(output);
const backendState = data.BackendState || "";
if (backendState === "Running") {
status.connected = true;
status.state = "connected";
} else if (backendState === "Starting") {
status.state = "connecting";
} else if (backendState === "NeedsLogin" || backendState === "NeedsMachineAuth") {
status.state = "needs-auth";
status.reason = backendState === "NeedsLogin" ? "Login required" : "Machine authorization required";
status.authUrl = data.AuthURL || "";
}
} catch (e) {
// JSON parsing failed - treat as disconnected unless it looks like an error
if (output.includes("error") || output.includes("Error") || output.includes("failed")) {
status.state = "disconnected";
status.reason = "Tailscale may not be running";
} else {
status.state = "disconnected";
}
}
return status;
}
function parseNetBirdStatus(output: string): var {
const status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
try {
const data = JSON.parse(output);
const mgmtConnected = data.management?.connected;
const signalConnected = data.signal?.connected;
if (mgmtConnected && signalConnected) {
status.connected = true;
status.state = "connected";
} else if (data.management?.error) {
const error = data.management.error;
if (error.includes("auth") || error.includes("login")) {
status.state = "needs-auth";
status.reason = "Authentication required";
} else {
status.reason = error;
}
}
} catch (e) {
status.state = "error";
status.reason = "Failed to parse status";
}
return status;
}
function parseWarpStatus(output: string): var {
const status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
if (output.includes("Connected")) {
status.connected = true;
status.state = "connected";
} else if (output.includes("Connecting")) {
status.state = "connecting";
} else if (output.includes("Unable") || output.includes("Registration Missing") || output.includes("registration") || output.includes("register")) {
status.state = "needs-auth";
status.reason = "WARP registration required";
} else if (!output.includes("Disconnected")) {
status.state = "error";
status.reason = "Unknown WARP status";
}
return status;
}
function parseWireGuardStatus(output: string): var {
const status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
const iface = root.currentConfig?.interface || "";
if (iface && output.includes(iface + ":")) {
status.connected = true;
status.state = "connected";
}
return status;
}
function parseStatusOutput(output: string): var {
switch (providerName) {
case "tailscale":
return parseTailscaleStatus(output);
case "netbird":
return parseNetBirdStatus(output);
case "warp":
return parseWarpStatus(output);
case "wireguard":
default:
return parseWireGuardStatus(output);
}
}
function extractAuthUrl(text: string): string {
const urlMatch = text.match(/(https?:\/\/[^\s]+)/);
return urlMatch ? urlMatch[1] : "";
}
function createAuthStatus(authUrl: string): var {
return {
connected: false,
state: "needs-auth",
reason: "Authentication required",
authUrl: authUrl
};
}
function updateStatus(newStatus: var): void {
const oldState = status.state;
if (newStatus.state === "needs-auth" && !newStatus.authUrl && status.authUrl) {
newStatus.authUrl = status.authUrl;
}
status = newStatus;
root.connected = newStatus.connected;
if (oldState !== newStatus.state) {
emitStatusToast(newStatus);
}
}
function emitStatusToast(statusObj: var): void {
if (!Config.utilities.toasts.vpnChanged) if (!Config.utilities.toasts.vpnChanged)
return; return;
const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN"; const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN";
if (connected) {
switch (statusObj.state) {
case "connected":
Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key"); Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key");
} else { break;
Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off"); case "disconnected":
if (status.connected) {
Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off");
}
break;
case "needs-auth":
const authMsg = statusObj.reason || "Authentication required";
Toaster.toast(qsTr("VPN authentication required"), qsTr("%1: %2").arg(displayName).arg(authMsg), "vpn_lock");
break;
case "error":
if (status.state === "connected" || status.state === "connecting" || status.state === "needs-auth") {
const errMsg = statusObj.reason || "Unknown error";
Toaster.toast(qsTr("VPN error"), qsTr("%1: %2").arg(displayName).arg(errMsg), "error");
}
break;
} }
} }
onStatusChanged: {
if (providerName === "warp" && status.state === "needs-auth" && status.reason.includes("registration")) {
warpRegisterProc.exec(["warp-cli", "registration", "new"]);
}
}
onProviderNameChanged: {
status = {
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
};
root.connected = false;
statusCheckTimer.start();
}
Component.onCompleted: root.enabled && statusCheckTimer.start() Component.onCompleted: root.enabled && statusCheckTimer.start()
Process { Process {
@ -127,7 +338,7 @@ Singleton {
Process { Process {
id: statusProc id: statusProc
command: ["ip", "link", "show"] command: root.getStatusCommand()
// qmllint disable incompatible-type // qmllint disable incompatible-type
environment: ({ environment: ({
// qmllint enable incompatible-type // qmllint enable incompatible-type
@ -136,8 +347,38 @@ Singleton {
}) })
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: {
const iface = root.currentConfig ? root.currentConfig.interface : ""; const newStatus = root.parseStatusOutput(text);
root.connected = iface && text.includes(iface + ":"); root.updateStatus(newStatus);
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim().length > 0) {
if (text.includes("doesn't appear to be running") || text.includes("failed to connect to local tailscaled") || text.includes("daemon is not running") || text.includes("not running") && (text.includes("netbird") || text.includes("warp"))) {
let cmd = "sudo systemctl start ";
switch (root.providerName) {
case "tailscale":
cmd += "tailscaled";
break;
case "netbird":
cmd += "netbird";
break;
case "warp":
cmd += "warp-svc";
break;
default:
cmd += root.providerName + "d";
break;
}
const errorStatus = {
connected: false,
state: "disconnected",
reason: `Service not running (run: ${cmd})`,
authUrl: ""
};
root.updateStatus(errorStatus);
}
}
} }
} }
} }
@ -145,12 +386,59 @@ Singleton {
Process { Process {
id: connectProc id: connectProc
onExited: statusCheckTimer.start() // qmllint disable signal-handler-parameters onExited: exitCode => { // qmllint disable signal-handler-parameters
if (exitCode !== 0) {
return;
}
if (root.providerName === "tailscale") {
Qt.callLater(() => {
if (root.status.state !== "needs-auth") {
statusCheckTimer.start();
}
});
} else if (root.status.state !== "needs-auth") {
statusCheckTimer.start();
}
}
stdout: SplitParser {
onRead: data => {
const authUrl = root.extractAuthUrl(data);
if (authUrl) {
root.updateStatus(root.createAuthStatus(authUrl));
}
}
}
stderr: StdioCollector { stderr: StdioCollector {
onStreamFinished: { onStreamFinished: {
const error = text.trim(); const error = text.trim();
if (error && !error.includes("[#]") && !error.includes("already exists")) {
console.warn("VPN connection error:", error); if (error.includes("Access denied") || error.includes("checkprefs access denied")) {
const errorStatus = {
connected: false,
state: "disconnected",
reason: "Permission denied. Run in terminal: sudo tailscale set --operator=$USER",
authUrl: ""
};
root.updateStatus(errorStatus);
return;
}
if (error.includes("Unknown device type") || error.includes("Protocol not supported")) {
const errorStatus = {
connected: false,
state: "disconnected",
reason: "WireGuard module not loaded. Run: sudo modprobe wireguard",
authUrl: ""
};
root.updateStatus(errorStatus);
return;
}
const authUrl = root.extractAuthUrl(error);
if (authUrl) {
root.updateStatus(root.createAuthStatus(authUrl));
} else if (error.includes("already exists")) { } else if (error.includes("already exists")) {
root.connected = true; root.connected = true;
} }
@ -172,6 +460,16 @@ Singleton {
} }
} }
Process {
id: warpRegisterProc
onExited: exitCode => { // qmllint disable signal-handler-parameters
if (exitCode === 0) {
statusCheckTimer.start();
}
}
}
Timer { Timer {
id: statusCheckTimer id: statusCheckTimer