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 {
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 {
enabled: utilities.enabled,
maxToasts: utilities.maxToasts,
@ -339,7 +357,7 @@ Singleton {
},
vpn: {
enabled: utilities.vpn.enabled,
provider: utilities.vpn.provider
provider: vpnProviders
},
quickToggles: utilities.quickToggles
};

View file

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

View file

@ -109,10 +109,13 @@ DeviceDetails {
inactiveOnColour: Colours.palette.m3onSecondaryContainer
onClicked: {
const provider = Config.utilities.vpn.provider[root.vpnProvider.index];
editVpnDialog.editIndex = root.vpnProvider.index;
editVpnDialog.providerName = root.vpnProvider.name;
editVpnDialog.displayName = root.vpnProvider.displayName;
editVpnDialog.interfaceName = root.vpnProvider.interface;
editVpnDialog.connectCmd = (provider && provider.connectCmd) ? provider.connectCmd.join(" ") : "";
editVpnDialog.disconnectCmd = (provider && provider.disconnectCmd) ? provider.disconnectCmd.join(" ") : "";
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");
if (VPN.connecting)
return qsTr("Connecting...");
if (VPN.connected)
switch (VPN.status.state) {
case "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 {
showTopMargin: true
label: qsTr("Enabled")
@ -200,6 +246,8 @@ DeviceDetails {
property string providerName: ""
property string displayName: ""
property string interfaceName: ""
property string connectCmd: ""
property string disconnectCmd: ""
function closeWithAnimation(): void {
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 {
Layout.topMargin: Appearance.spacing.normal
Layout.fillWidth: true
@ -376,12 +500,23 @@ DeviceDetails {
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
if (i === editVpnDialog.editIndex) {
providers.push({
name: editVpnDialog.providerName,
const hasCommands = editVpnDialog.connectCmd.length > 0 && editVpnDialog.disconnectCmd.length > 0;
const newProvider = {
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 {
providers.push(Config.utilities.vpn.provider[i]);
}

View file

@ -159,15 +159,38 @@ ColumnLayout {
StyledText {
Layout.fillWidth: true
text: {
if (modelData.enabled && VPN.connected)
return qsTr("Connected");
if (modelData.enabled && VPN.connecting)
if (!modelData.enabled)
return qsTr("Disabled");
if (VPN.connecting)
return qsTr("Connecting...");
if (modelData.enabled)
switch (VPN.status.state) {
case "connected":
return qsTr("Connected");
case "disconnected":
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.weight: modelData.enabled && VPN.connected ? 500 : 400
elide: Text.ElideRight
@ -271,6 +294,8 @@ ColumnLayout {
property string providerName: ""
property string displayName: ""
property string interfaceName: ""
property string connectCmd: ""
property string disconnectCmd: ""
function showProviderSelection(): void {
currentState = "selection";
@ -286,6 +311,8 @@ ColumnLayout {
providerName = providerType;
displayName = defaultDisplayName;
interfaceName = "";
connectCmd = "";
disconnectCmd = "";
if (currentState === "selection") {
transitionToForm.start();
@ -304,6 +331,8 @@ ColumnLayout {
providerName = isObject ? (provider.name || "custom") : String(provider);
displayName = isObject ? (provider.displayName || providerName) : providerName;
interfaceName = isObject ? (provider.interface || "") : "";
connectCmd = isObject && provider.connectCmd ? provider.connectCmd.join(" ") : "";
disconnectCmd = isObject && provider.disconnectCmd ? provider.disconnectCmd.join(" ") : "";
currentState = "form";
open();
@ -491,7 +520,7 @@ ColumnLayout {
TextButton {
Layout.fillWidth: true
text: qsTr("WireGuard (Custom)")
text: qsTr("WireGuard")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
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 {
Layout.topMargin: Appearance.spacing.normal
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 {
Layout.topMargin: Appearance.spacing.normal
Layout.fillWidth: true
@ -620,19 +735,37 @@ ColumnLayout {
TextButton {
Layout.fillWidth: true
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
inactiveOnColour: Colours.palette.m3onPrimaryContainer
onClicked: {
const providers = [];
const hasCommands = vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0;
const newProvider = {
name: vpnDialog.providerName,
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) {
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++) {
if (i === vpnDialog.editIndex) {
providers.push(newProvider);

View file

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

View file

@ -10,6 +10,12 @@ Singleton {
id: root
property bool connected: false
property var status: ({
connected: false,
state: "disconnected",
reason: "",
authUrl: ""
})
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)
@ -19,7 +25,7 @@ Singleton {
}
readonly property bool isCustomProvider: typeof providerInput === "object"
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: {
const name = providerName;
const iface = interfaceName;
@ -30,7 +36,7 @@ Singleton {
return {
connectCmd: custom.connectCmd || defaults.connectCmd,
disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd,
interface: custom.interface || defaults.interface,
interface: custom.iface || defaults.interface,
displayName: custom.displayName || defaults.displayName
};
}
@ -53,7 +59,7 @@ Singleton {
displayName: "Warp"
},
"netbird": {
connectCmd: ["netbird", "up"],
connectCmd: ["netbird", "up", "--no-browser"],
disconnectCmd: ["netbird", "down"],
interface: "wt0",
displayName: "NetBird"
@ -75,6 +81,10 @@ Singleton {
}
function connect(): void {
if (status.state === "needs-auth" && status.authUrl) {
emitStatusToast(status);
return;
}
if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) {
connectProc.exec(root.currentConfig.connectCmd);
}
@ -87,11 +97,7 @@ Singleton {
}
function toggle(): void {
if (connected) {
disconnect();
} else {
connect();
}
connected ? disconnect() : connect();
}
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)
return;
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");
} else {
Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off");
break;
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()
Process {
@ -127,7 +338,7 @@ Singleton {
Process {
id: statusProc
command: ["ip", "link", "show"]
command: root.getStatusCommand()
// qmllint disable incompatible-type
environment: ({
// qmllint enable incompatible-type
@ -136,8 +347,38 @@ Singleton {
})
stdout: StdioCollector {
onStreamFinished: {
const iface = root.currentConfig ? root.currentConfig.interface : "";
root.connected = iface && text.includes(iface + ":");
const newStatus = root.parseStatusOutput(text);
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 {
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 {
onStreamFinished: {
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")) {
root.connected = true;
}
@ -172,6 +460,16 @@ Singleton {
}
}
Process {
id: warpRegisterProc
onExited: exitCode => { // qmllint disable signal-handler-parameters
if (exitCode === 0) {
statusCheckTimer.start();
}
}
}
Timer {
id: statusCheckTimer