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 {
|
||||
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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -62,6 +62,6 @@ JsonObject {
|
|||
|
||||
component Vpn: JsonObject {
|
||||
property bool enabled: false
|
||||
property list<var> provider: ["netbird"]
|
||||
property list<var> provider: []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
334
services/VPN.qml
334
services/VPN.qml
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue