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:
parent
c588794b20
commit
5c59e4490a
6 changed files with 624 additions and 38 deletions
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
334
services/VPN.qml
334
services/VPN.qml
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue