From 6391bc85c4832b0c886b9bdfb13b969cd0702b19 Mon Sep 17 00:00:00 2001 From: inorishio Date: Sun, 18 Oct 2026 16:29:12 +0200 Subject: [PATCH] Moved network related files, added network sub pages --- Modules/Settings/PageCompRegistry.qml | 21 + Modules/Settings/Pages/Network/AddNetwork.qml | 256 +++ .../Settings/Pages/Network/AllNetworks.qml | 345 ++++ .../Settings/Pages/Network/NetworkPage.qml | 55 + .../OldNetwork.qml} | 3 +- .../Settings/Pages/Network/SavedNetworks.qml | 118 ++ Services/Nmcli.qml | 1834 +++++++++++++++++ 7 files changed, 2631 insertions(+), 1 deletion(-) create mode 100644 Modules/Settings/Pages/Network/AddNetwork.qml create mode 100644 Modules/Settings/Pages/Network/AllNetworks.qml create mode 100644 Modules/Settings/Pages/Network/NetworkPage.qml rename Modules/Settings/Pages/{NetworkPage.qml => Network/OldNetwork.qml} (99%) create mode 100644 Modules/Settings/Pages/Network/SavedNetworks.qml create mode 100644 Services/Nmcli.qml diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index f1f7263..732c2b4 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -12,6 +12,7 @@ import qs.Modules.Settings.Pages.Apps import qs.Modules.Settings.Pages.Panels import qs.Modules.Settings.Pages.Panels.Bar import qs.Modules.Settings.Pages.Services +import qs.Modules.Settings.Pages.Network import qs.Services QtObject { @@ -50,6 +51,26 @@ QtObject { NetworkPage { } } + + Component { + AllNetworks { + } + } + + Component { + SavedNetworks { + } + } + + Component { + AddNetwork { + } + } + + Component { + OldNetwork { + } + } } }, Component { diff --git a/Modules/Settings/Pages/Network/AddNetwork.qml b/Modules/Settings/Pages/Network/AddNetwork.qml new file mode 100644 index 0000000..72959cc --- /dev/null +++ b/Modules/Settings/Pages/Network/AddNetwork.qml @@ -0,0 +1,256 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Internal +import ZShell.Blobs +import ZShell.Services +import ZShell.Components +import qs.Components +import qs.Modules.Settings.Common + +PageBase { + id: root + + property bool connecting: false + property bool failed: false + + // Security model: index 0 = none, 1 = WPA/WPA2/WPA3 personal. + readonly property bool secured: securitySelect.active !== noneItem + property bool success: false + + function submit(): void { + const ssid = ssidField.text.trim(); + if (ssid.length === 0) { + ssidField.isError = true; + ssidField.forceActiveFocus(); + return; + } + if (root.secured && passwordField.text.length < 8) { + passwordField.isError = true; + passwordField.forceActiveFocus(); + return; + } + + root.failed = false; + root.connecting = true; + + Nmcli.addHiddenNetwork(ssid, root.secured ? passwordField.text : "", root.secured ? "wpa" : "none", hiddenToggle.checked, result => { + root.connecting = false; + if (result && result.success) { + root.success = true; + root.nState.closeSubPage(); + } else { + root.failed = true; + if (root.secured) + passwordField.isError = true; + // Clean up the half-created profile so a retry starts fresh. + Nmcli.forgetNetwork(ssid); + } + }); + } + + isSubPage: true + title: Tr.tr("Add network") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.large + width: root.cappedWidth + + Connections { + function onSubPageClosed(): void { + if (root.success) + return; + + const ssid = ssidField.text.trim(); + if (ssid) + Nmcli.forgetNetwork(ssid); + } + + target: root.nState + } + + CustomText { + Layout.fillWidth: true + Layout.leftMargin: Tokens.padding.extraSmall + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + text: Tr.tr("Enter the details below to manually connect to a network.") + wrapMode: Text.WordWrap + } + + CustomTextField { + id: ssidField + + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.extraSmall + errorText: Tr.tr("Network name is required") + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText + leadingIcon: "wifi" + // TRANSLATORS: SSID is a protocol term, leave it untranslated + placeholderText: Tr.tr("Network name (SSID)") + // TRANSLATORS: sample network name, translate the e.g. but the name is a placeholder + supportingText: Tr.tr("e.g. MyHiddenNetwork") + + onAccepted: root.secured ? passwordField.forceActiveFocus() : root.submit() + } + + ToggleRow { + id: hiddenToggle + + checked: true + first: true + subtext: Tr.tr("Actively probe for a network that doesn't broadcast its name") + text: Tr.tr("Hidden network") + } + + SelectRow { + id: securitySelect + + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + fallbackIcon: "lock" + // TRANSLATORS: WPA/WPA2/WPA3 are protocol names, leave them untranslated + fallbackText: Tr.tr("WPA/WPA2/WPA3 Personal") + // label: Tr.tr("Security") + last: !root.secured + + Behavior on bottomLeftRadius { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on bottomRightRadius { + Anim { + type: Anim.DefaultEffects + } + } + menuItems: [ + MenuItem { + icon: "lock" + // TRANSLATORS: WPA/WPA2/WPA3 are protocol names, leave them untranslated + text: Tr.tr("WPA/WPA2/WPA3 Personal") + }, + MenuItem { + id: noneItem + + icon: "lock_open" + text: Tr.trCtx("None (open)", "wifi security type") + } + ] + } + + Item { + Layout.bottomMargin: root.secured ? 0 : -parent.spacing + Layout.fillWidth: true + implicitHeight: root.secured ? passwordField.implicitHeight : 0 + opacity: root.secured ? 1 : 0 + + Behavior on Layout.bottomMargin { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + CustomTextField { + id: passwordField + + anchors.left: parent.left + anchors.right: parent.right + echoMode: TextInput.Password + enabled: root.secured + errorText: root.failed ? Tr.tr("Connection failed — check the password") : Tr.tr("Password must be at least 8 characters") + leadingIcon: "key" + placeholderText: Tr.tr("Password") + // TRANSLATORS: WPA is a protocol name, leave it untranslated + supportingText: Tr.tr("WPA passwords are at least 8 characters") + + onAccepted: root.submit() + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight + Layout.topMargin: Tokens.spacing.extraSmall - parent.spacing + spacing: Tokens.spacing.small + + TextButton { + Layout.fillHeight: true + horizontalPadding: Tokens.padding.extraLarge + isRound: true + text: Tr.trCtx("Cancel", "button") + type: TextButton.Tonal + + onClicked: root.nState.closeSubPage() + } + + // Connect button — swaps to a loading spinner while connecting, + // matching the Wi-Fi list connect animation. + ButtonBase { + id: connectBtn + + implicitHeight: connectMetrics.height + Tokens.padding.medium * 2 + implicitWidth: connectMetrics.width + Tokens.padding.extraLarge * 2 + // inactiveColour: Colours.palette.m3primary + // inactiveOnColour: Colours.palette.m3onPrimary + isRound: true + shapeMorph: true + + // stateLayer.disabled: root.connecting || ssidField.text.trim().length === 0 + + onClicked: { + if (!root.connecting && ssidField.text.trim().length > 0) + root.submit(); + } + + TextMetrics { + id: connectMetrics + + font: connectBtn.font + text: Tr.tr("Connect") + } + + Anim { + id: connectContent + + // anchors.centerIn: parent + // inAnimType: Anim.SlowEffects + // outAnimType: Anim.SlowEffects + // sourceComp: root.connecting ? connectLoadingComp : connectTextComp + } + + Component { + id: connectLoadingComp + + LoadingIndicator { + color: connectBtn.onColour + implicitSize: Math.round(Tokens.font.body.medium.pointSize * 1.4) + } + } + + Component { + id: connectTextComp + + CustomText { + color: connectBtn.onColour + font: connectBtn.font + text: connectMetrics.text + } + } + } + } + } +} diff --git a/Modules/Settings/Pages/Network/AllNetworks.qml b/Modules/Settings/Pages/Network/AllNetworks.qml new file mode 100644 index 0000000..a98705b --- /dev/null +++ b/Modules/Settings/Pages/Network/AllNetworks.qml @@ -0,0 +1,345 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Helpers.Network +import qs.Services +import qs.Modules.Settings.Common + +PageBase { + id: root + + flickable.bottomMargin: Tokens.padding.extraExtraLarge * 2 // Extra scrolling space at the bottom + + isSubPage: true + title: Tr.tr("All networks") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + // spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + Timer { + interval: 300 // GlobalConfig.nexus.networkRescanInterval + repeat: true + running: root.visible && Nmcli.wifiEnabled + triggeredOnStart: true + + onTriggered: Nmcli.rescanWifi() + } + + ConnectedRect { + Layout.fillWidth: true + first: true + implicitHeight: headerLayout.implicitHeight + Tokens.padding.medium * 2 + + RowLayout { + id: headerLayout + + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: 1 + + // spacing: Tokens.spacing.extraSmall / 2 + + CustomText { + font: Tokens.font.title.small + text: Tr.trCtx("Filters", "network filter heading") + } + + Item { + Layout.fillWidth: true + } + + FilterButton { + id: savedFilter + + function internalFilter(ap: Nmcli.AccessPoint): bool { + return Nmcli.hasSavedProfile(ap.ssid); + } + + bottomLeftRadius: pressed ? pressedRadius : implicitHeight / 2 + text: Tr.trCtx("Saved", "network filter") + topLeftRadius: pressed ? pressedRadius : implicitHeight / 2 + + Behavior on bottomLeftRadius { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on topLeftRadius { + Anim { + type: Anim.DefaultEffects + } + } + } + + FilterButton { + id: secureFilter + + function internalFilter(ap: Nmcli.AccessPoint): bool { + return ap.security !== "none"; + } + + text: Tr.trCtx("Secured", "network filter") + } + + FilterButton { + id: highFreqFilter + + function internalFilter(ap: Nmcli.AccessPoint): bool { + return ap.frequency >= 4900 && ap.frequency <= 5900; + } + + text: Tr.trCtx("5 GHz", "network filter") + } + + FilterButton { + id: lowFreqFilter + + function internalFilter(ap: Nmcli.AccessPoint): bool { + return ap.frequency >= 2400 && ap.frequency <= 2500; + } + + bottomRightRadius: pressed ? pressedRadius : implicitHeight / 2 + text: Tr.trCtx("2.4 GHz", "network filter") + topRightRadius: pressed ? pressedRadius : implicitHeight / 2 + + Behavior on bottomRightRadius { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on topRightRadius { + Anim { + type: Anim.DefaultEffects + } + } + } + } + } + + NetworkList { + id: networkList + + function networkFilter(ap: Nmcli.AccessPoint): bool { + return savedFilter.filter(ap) && secureFilter.filter(ap) && highFreqFilter.filter(ap) && lowFreqFilter.filter(ap); + } + + enableFilter: true + last: true + // nState: root.nState + } + } + + component FilterButton: IconTextButton { + id: filter + + property int filterState // 0 = default, 1 = on, 2 = negate + + function filter(ap: Nmcli.AccessPoint): bool { + if (filterState === 0) + return true; + if (filterState === 1) + return internalFilter(ap); + return !internalFilter(ap); + } + + function internalFilter(ap: Nmcli.AccessPoint): bool { + return true; + } + + defaultRadius: filterState > 0 ? implicitHeight / 2 : Tokens.rounding.small + icon: ["check_indeterminate_small", "check", "close"][filterState] + // inactiveColour: [Colours.palette.m3secondaryContainer, Colours.palette.m3secondary, Colours.palette.m3tertiary][filterState] + // inactiveOnColour: [Colours.palette.m3onSecondaryContainer, Colours.palette.m3onSecondary, Colours.palette.m3onTertiary][filterState] + pressedRadius: Tokens.rounding.extraSmall + + // spacing: Tokens.spacing.extraSmall + + onClicked: filterState = (filterState + 1) % 3 + } + component NetworkList: ItemList { + id: netList + + property bool enableFilter + property int limit: 0 // 0 = show all + + // required property NexusState nState + + signal networkSelected(ap: Nmcli.AccessPoint) + + function networkFilter(ap: Nmcli.AccessPoint): bool { + return true; + } + + extraHeight: Nmcli.scanning ? Tokens.rounding.extraSmall : 0 // Inline so it isn't affected by anim + list.anchors.top: scanningIndicator.bottom + placeholderIcon: Nmcli.wifiEnabled ? "wifi_find" : "signal_wifi_off" + placeholderText: Nmcli.wifiEnabled ? Tr.tr("No networks found") : Tr.tr("Wi-Fi disabled") + showList: Nmcli.wifiEnabled + + delegate: StateLayer { + id: network + + property bool currentSelected + required property int index + required property var modelData + property real textOpacity: disabled ? 0.5 : 1 + + anchors.fill: undefined + anchors.left: netList.list.contentItem.left + anchors.right: netList.list.contentItem.right + bottomLeftRadius: netList?.last && index === netList.list.count - 1 ? Tokens.rounding.extraLarge : radius + bottomRightRadius: netList?.last && index === netList.list.count - 1 ? Tokens.rounding.extraLarge : radius + // disabled: currentSelected || Nmcli.connectingSsid() === modelData.ssid + implicitHeight: networkLayout.implicitHeight + networkLayout.anchors.margins * 2 + radius: Tokens.rounding.extraSmall + + Behavior on textOpacity { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: { + if (!modelData.active) { + NetworkConnection.handleConnect(modelData); + currentSelected = true; + netList.networkSelected(modelData); + } else { + // Active network: open its detail/settings sub-page. + netList.nState.selectedNetworkSsid = modelData.ssid; + netList.nState.networkDetailsFromSaved = false; + netList.nState.openSubPage(3); + } + } + + Connections { + function onActiveChanged(): void { + if (network.modelData.active) + network.currentSelected = false; + } + + target: network.modelData + } + + Connections { + function onNetworkSelected(ap: Nmcli.AccessPoint): void { + if (ap !== network.modelData) + network.currentSelected = false; + } + + target: netList + } + + RowLayout { + id: networkLayout + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.extraLarge + anchors.margins: Tokens.padding.large + anchors.rightMargin: Tokens.padding.extraLarge + + // spacing: Tokens.spacing.extraSmall + + MaterialIcon { + color: network.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + opacity: network.textOpacity + text: Icons.getNetworkIcon(network.modelData.strength) + } + + ColumnLayout { + Layout.fillWidth: true + opacity: network.textOpacity + + // spacing: 0 + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font: Tokens.font.body.small + text: network.modelData.ssid + } + + CustomText { + Layout.fillWidth: true + color: Colours.palette.m3outline + elide: Text.ElideRight + font: Tokens.font.label.small + text: { + const net = network.modelData; + let status = ""; + if (net.active) + status = Tr.trCtx("Connected", "network connected"); + else if (Nmcli.hasSavedProfile(net.ssid)) + status = Tr.trCtx("Saved", "network saved"); + if (status) + // TRANSLATORS: %1 = security type, %2 = connection status + return Tr.trCtx("Security: %1 • %2", "network security and status").arg(net.security).arg(status); + return Tr.trCtx("Security: %1", "network security").arg(net.security); + } + } + } + + // Anim { + // // sourceComp: Nmcli.connectingSsid() === network.modelData.ssid ? loadingComp : iconComp + // + // Component { + // id: iconComp + // + // MaterialIcon { + // color: network.modelData.active ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + // fontStyle: Tokens.font.icon.medium + // opacity: network.textOpacity + // text: network.modelData.active ? "settings" : "lock" + // } + // } + // + // Component { + // id: loadingComp + // + // LoadingIndicator { + // implicitSize: Math.round(Tokens.font.icon.medium.pointSize * 1.3) + // } + // } + // } + } + } + + // model: ScriptModel { + // values: { + // const connecting = Nmcli.connectingSsid(); + // // Lower rank sorts higher in the list + // const rank = n => n.active ? 0 : n.ssid === connecting ? 1 : Nmcli.hasSavedProfile(n.ssid) ? 2 : 3; + // const sorted = [...Nmcli.networks].sort((a, b) => rank(a) - rank(b) || b.strength - a.strength); + // if (netList.limit > 0 && sorted.length > netList.limit) + // sorted.length = netList.limit; + // return netList.enableFilter ? sorted.filter(netList.networkFilter) : sorted; + // } + // } + + // StyledProgressBar { + // id: scanningIndicator + // + // anchors.left: parent.left + // anchors.margins: 1 + // anchors.right: parent.right + // implicitHeight: Nmcli.scanning ? Tokens.rounding.extraSmall : 0 + // indeterminate: true + // + // Behavior on implicitHeight { + // Anim { + // type: Anim.DefaultEffects + // } + // } + // } + } +} diff --git a/Modules/Settings/Pages/Network/NetworkPage.qml b/Modules/Settings/Pages/Network/NetworkPage.qml new file mode 100644 index 0000000..a0d1a44 --- /dev/null +++ b/Modules/Settings/Pages/Network/NetworkPage.qml @@ -0,0 +1,55 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import qs.Modules.Settings.Common +import qs.Modules.Bar.Popouts.Network +import qs.Components +import qs.Helpers.Network +import qs.Services + +PageBase { + id: root + + title: qsTr("Begin page") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + NavRow { + first: true + icon: "dock_to_bottom" + // settingAnchor: "panels-bar" + text: qsTr("Show all networks") + + onClicked: root.sState.openSubPage(1) + } + + NavRow { + icon: "dock_to_bottom" + text: qsTr("Saved networks") + + onClicked: root.sState.openSubPage(2) + } + + NavRow { + icon: "dock_to_bottom" + text: qsTr("Add network") + + onClicked: root.sState.openSubPage(3) + } + + NavRow { + icon: "dock_to_bottom" + last: true + text: qsTr("Old Network Page") + + onClicked: root.sState.openSubPage(4) + } + } +} diff --git a/Modules/Settings/Pages/NetworkPage.qml b/Modules/Settings/Pages/Network/OldNetwork.qml similarity index 99% rename from Modules/Settings/Pages/NetworkPage.qml rename to Modules/Settings/Pages/Network/OldNetwork.qml index 13ef89e..9b52a49 100644 --- a/Modules/Settings/Pages/NetworkPage.qml +++ b/Modules/Settings/Pages/Network/OldNetwork.qml @@ -13,7 +13,8 @@ import qs.Services PageBase { id: root - title: qsTr("Network") + isSubPage: true + title: qsTr("Old network page") Component.onCompleted: Network.active = true Component.onDestruction: Network.active = false diff --git a/Modules/Settings/Pages/Network/SavedNetworks.qml b/Modules/Settings/Pages/Network/SavedNetworks.qml new file mode 100644 index 0000000..b505bf9 --- /dev/null +++ b/Modules/Settings/Pages/Network/SavedNetworks.qml @@ -0,0 +1,118 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Internal +import ZShell.Blobs +import ZShell.Services +import ZShell.Components +import qs.Components +import qs.Modules.Settings.Common + +PageBase { + id: root + + isSubPage: true + title: Tr.tr("Saved networks") + + Component.onCompleted: Nmcli.loadSavedConnections(() => {}) + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + ItemList { + id: savedList + + first: true + last: true + placeholderIcon: "wifi_find" + placeholderText: Tr.tr("No saved networks") + showList: true + + delegate: StateLayer { + id: saved + + readonly property var ap: Nmcli.findNetwork(modelData) + required property int index + readonly property bool isActive: !!Nmcli.active && Nmcli.active.ssid === modelData + required property var modelData + + anchors.fill: undefined + anchors.left: savedList.list.contentItem.left + anchors.right: savedList.list.contentItem.right + bottomLeftRadius: index === savedList?.list.count - 1 ? Tokens.rounding.extraLarge : radius + bottomRightRadius: index === savedList?.list.count - 1 ? Tokens.rounding.extraLarge : radius + implicitHeight: savedLayout.implicitHeight + savedLayout.anchors.margins * 2 + radius: Tokens.rounding.extraSmall + topLeftRadius: index === 0 ? Tokens.rounding.extraLarge : radius + topRightRadius: index === 0 ? Tokens.rounding.extraLarge : radius + + onClicked: { + root.nState.selectedNetworkSsid = saved.modelData; + root.nState.networkDetailsFromSaved = true; + root.nState.openSubPage(3); // Shared network detail/edit sub-page + } + + RowLayout { + id: savedLayout + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.extraLarge + anchors.margins: Tokens.padding.large + anchors.rightMargin: Tokens.padding.extraLarge + spacing: Tokens.spacing.medium + + MaterialIcon { + color: saved.isActive ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + text: saved.ap ? Icons.getNetworkIcon(saved.ap.strength, !["", "none"].includes(Nmcli.savedSecurityFor(saved.modelData))) : "signal_wifi_off" + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font: Tokens.font.body.small + text: saved.modelData + } + + CustomText { + Layout.fillWidth: true + color: saved.isActive ? Colours.palette.m3primary : Colours.palette.m3outline + elide: Text.ElideRight + font: Tokens.font.label.small + text: { + let security; + if (saved.ap) + security = saved.ap.security || Tr.trCtx("Open", "wifi security type"); + else + security = Nmcli.securityLabel(Nmcli.savedSecurityFor(saved.modelData)) || Tr.trCtx("Unknown", "unknown wifi security"); + if (saved.isActive) + // TRANSLATORS: %1 = security type + return Tr.trCtx("Connected • %1", "network connected with security").arg(security); + return security; + } + } + } + + MaterialIcon { + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + text: "chevron_right" + } + } + } + model: ScriptModel { + values: [...Nmcli.savedConnectionSsids].sort((a, b) => a.localeCompare(b)) + } + } + } +} diff --git a/Services/Nmcli.qml b/Services/Nmcli.qml new file mode 100644 index 0000000..8318182 --- /dev/null +++ b/Services/Nmcli.qml @@ -0,0 +1,1834 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io + +// import Caelestia.I18n + +Singleton { + id: root + + readonly property AccessPoint active: networks.find(n => n.active) ?? null + property string activeConnection: "" + readonly property EthernetDevice activeEthernet: ethernetDevices.find(d => d.connected) ?? null + property string activeInterface: "" + property list activeProcesses: [] + readonly property bool connecting: wirelessInterfaces.some(i => isConnectingState(i.state)) + readonly property alias connectionCheckTimer: connectionCheckTimer + readonly property string connectionListFields: "NAME,TYPE" + readonly property string connectionParamBssid: "802-11-wireless.bssid" + readonly property string connectionParamConName: "con-name" + readonly property string connectionParamHidden: "802-11-wireless.hidden" + readonly property string connectionParamIfname: "ifname" + readonly property string connectionParamPassword: "password" + readonly property string connectionParamSsid: "ssid" + readonly property string connectionParamType: "type" + readonly property string connectionTypeWireless: "802-11-wireless" + property int currentSsidQueryIndex: 0 + property var deviceStatus: null + readonly property string deviceStatusFields: "DEVICE,TYPE,STATE,CONNECTION" + readonly property string deviceTypeEthernet: "ethernet" + + // Constants + readonly property string deviceTypeWifi: "wifi" + property string ethernetDataUsage: "" + property var ethernetDeviceDetails: null + readonly property list ethernetDevices: [] + property var ethernetInterfaces: [] + // Link speed of the active ethernet interface (from sysfs), e.g. "1 Gbps". + property string ethernetSpeed: "" + // True when at least one wired device has a carrier (cable plugged in). + // nmcli reports "unavailable" for ethernet NICs with no link, so we treat + // anything other than that as a usable connection. + readonly property bool hasAvailableEthernet: ethernetDevices.some(d => d.state !== "unavailable") + readonly property alias immediateCheckTimer: immediateCheckTimer + property bool isConnected: false + readonly property string keyMgmtWpaPsk: "wpa-psk" + readonly property string networkDetailFields: "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY" + readonly property string networkListFields: "SSID,SIGNAL,SECURITY" + readonly property list networks: [] + readonly property string nmcliCommandConnection: "connection" + readonly property string nmcliCommandDevice: "device" + readonly property string nmcliCommandRadio: "radio" + readonly property string nmcliCommandWifi: "wifi" + property var pendingConnection: null + // Map of saved Wi-Fi SSID (lowercased) -> security type + property var savedConnectionSecurity: ({}) + property list savedConnectionSsids: [] + property list savedConnections: [] + readonly property bool scanning: rescanProc.running + readonly property string securityKeyMgmt: "802-11-wireless-security.key-mgmt" + readonly property string securityPsk: "802-11-wireless-security.psk" + property var wifiConnectionQueue: [] + property bool wifiEnabled: true + property var wirelessDeviceDetails: null + property var wirelessInterfaces: [] + readonly property string wirelessSsidField: "802-11-wireless.ssid" + + signal connectionFailed(string ssid) + + function activateConnection(connectionName: string, callback: var): void { + executeCommand([root.nmcliCommandConnection, "up", connectionName], result => { + if (callback) + callback(result); + }); + } + + // Adds and connects to an SSID by name. When hidden is true the profile is + // created with 802-11-wireless.hidden=yes so NetworkManager actively probes + // for it. + function addHiddenNetwork(ssid: string, password: string, security: string, hidden: bool, callback: var): void { + if (!ssid || ssid.length === 0) { + if (callback) + callback({ + success: false, + output: "", + error: "No SSID specified", + exitCode: -1 + }); + return; + } + + const isSecure = security && security !== "none"; + + // Remove any stale profile with the same name first so we don't collide. + checkAndDeleteConnection(ssid, () => { + let cmd = [root.nmcliCommandConnection, "add", root.connectionParamType, root.deviceTypeWifi, root.connectionParamConName, ssid, root.connectionParamIfname, "*", root.connectionParamSsid, ssid, root.connectionParamHidden, hidden ? "yes" : "no"]; + + if (isSecure) { + cmd.push(root.securityKeyMgmt, root.keyMgmtWpaPsk, root.securityPsk, password); + } + + executeCommand(cmd, result => { + if (result.success) { + loadSavedConnections(() => {}); + activateConnection(ssid, callback); + } else { + const hasDuplicateWarning = result.error && (result.error.includes("another connection with the name") || result.error.includes("Reference the connection by its uuid")); + + if (hasDuplicateWarning) { + loadSavedConnections(() => {}); + activateConnection(ssid, callback); + } else if (callback) { + callback(result); + } + } + }); + }); + } + + function bringInterfaceDown(interfaceName: string, callback: var): void { + if (interfaceName && interfaceName.length > 0) { + executeCommand([root.nmcliCommandDevice, "disconnect", interfaceName], result => { + if (callback) { + callback(result); + } + }); + } else { + if (callback) + callback({ + success: false, + output: "", + error: "No interface specified", + exitCode: -1 + }); + } + } + + function bringInterfaceUp(interfaceName: string, callback: var): void { + if (interfaceName && interfaceName.length > 0) { + executeCommand([root.nmcliCommandDevice, "connect", interfaceName], result => { + if (callback) { + callback(result); + } + }); + } else { + if (callback) + callback({ + success: false, + output: "", + error: "No interface specified", + exitCode: -1 + }); + } + } + + function checkAndDeleteConnection(ssid: string, callback: var): void { + executeCommand([root.nmcliCommandConnection, "show", ssid], result => { + if (result.success) { + executeCommand([root.nmcliCommandConnection, "delete", ssid], deleteResult => { + Qt.callLater(() => { + if (callback) + callback(); + }, 300); + }); + } else { + if (callback) + callback(); + } + }); + } + + function checkPendingConnection(): void { + if (root.pendingConnection) { + Qt.callLater(() => { + const connected = root.active && root.active.ssid === root.pendingConnection.ssid; + if (connected) { + connectionCheckTimer.stop(); + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + if (root.pendingConnection.callback) { + root.pendingConnection.callback({ + success: true, + output: "Connected", + error: "", + exitCode: 0 + }); + } + root.pendingConnection = null; + } else { + if (!immediateCheckTimer.running) { + immediateCheckTimer.start(); + } + } + }); + } + } + + function cidrToSubnetMask(cidr: string): string { + const cidrNum = parseInt(cidr, 10); + if (isNaN(cidrNum) || cidrNum < 0 || cidrNum > 32) { + return ""; + } + + const mask = (0xffffffff << (32 - cidrNum)) >>> 0; + const octet1 = (mask >>> 24) & 0xff; + const octet2 = (mask >>> 16) & 0xff; + const octet3 = (mask >>> 8) & 0xff; + const octet4 = mask & 0xff; + + return `${octet1}.${octet2}.${octet3}.${octet4}`; + } + + function connectEthernet(connectionName: string, interfaceName: string, callback: var): void { + if (connectionName && connectionName.length > 0) { + executeCommand([root.nmcliCommandConnection, "up", connectionName], result => { + if (result.success) { + Qt.callLater(() => { + getEthernetInterfaces(() => {}); + if (interfaceName && interfaceName.length > 0) { + Qt.callLater(() => { + getEthernetDeviceDetails(interfaceName, () => {}); + }, 1000); + } + }, 500); + } + if (callback) + callback(result); + }); + } else if (interfaceName && interfaceName.length > 0) { + executeCommand([root.nmcliCommandDevice, "connect", interfaceName], result => { + if (result.success) { + Qt.callLater(() => { + getEthernetInterfaces(() => {}); + Qt.callLater(() => { + getEthernetDeviceDetails(interfaceName, () => {}); + }, 1000); + }, 500); + } + if (callback) + callback(result); + }); + } else { + if (callback) + callback({ + success: false, + output: "", + error: "No connection name or interface specified", + exitCode: -1 + }); + } + } + + function connectToNetwork(ssid: string, password: string, bssid: string, callback: var): void { + connectWireless(ssid, password, bssid, callback); + } + + function connectToNetworkWithPasswordCheck(ssid: string, isSecure: bool, callback: var, bssid: string): void { + if (isSecure) { + const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0; + connectWireless(ssid, "", bssid, result => { + if (result.success) { + if (callback) + callback({ + success: true, + usedSavedPassword: true, + output: result.output, + error: "", + exitCode: 0 + }); + } else if (result.needsPassword) { + if (callback) + callback({ + success: false, + needsPassword: true, + output: result.output, + error: result.error, + exitCode: result.exitCode + }); + } else { + if (callback) + callback(result); + } + }); + } else { + connectWireless(ssid, "", bssid, callback); + } + } + + function connectWireless(ssid: string, password: string, bssid: string, callback: var, retryCount: int): void { + const hasBssid = bssid !== undefined && bssid !== null && bssid.length > 0; + const retries = retryCount !== undefined ? retryCount : 0; + const maxRetries = 2; + + if (callback) { + root.pendingConnection = { + ssid: ssid, + bssid: hasBssid ? bssid : "", + callback: callback, + retryCount: retries + }; + connectionCheckTimer.start(); + immediateCheckTimer.checkCount = 0; + immediateCheckTimer.start(); + } + + if (password && password.length > 0 && hasBssid) { + const bssidUpper = bssid.toUpperCase(); + createConnectionWithPassword(ssid, bssidUpper, password, callback); + return; + } + + let cmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "connect", ssid]; + if (password && password.length > 0) { + cmd.push(root.connectionParamPassword, password); + } + executeCommand(cmd, result => { + if (result.needsPassword && callback) { + if (callback) + callback(result); + return; + } + + if (!result.success && root.pendingConnection && retries < maxRetries) { + console.warn(lc, "Connection failed, retrying... (attempt " + (retries + 1) + "/" + maxRetries + ")"); + Qt.callLater(() => { + connectWireless(ssid, password, bssid, callback, retries + 1); + }, 1000); + } else if (!result.success && root.pendingConnection) {} else if (result.success && callback) {} else if (!result.success && !root.pendingConnection) { + if (callback) + callback(result); + } + }); + } + + function connectingSsid(): string { + const iface = root.wirelessInterfaces.find(i => isConnectingState(i.state)); + return iface ? iface.connection : ""; + } + + function createConnectionWithPassword(ssid: string, bssidUpper: string, password: string, callback: var): void { + checkAndDeleteConnection(ssid, () => { + const cmd = [root.nmcliCommandConnection, "add", root.connectionParamType, root.deviceTypeWifi, root.connectionParamConName, ssid, root.connectionParamIfname, "*", root.connectionParamSsid, ssid, root.connectionParamBssid, bssidUpper, root.securityKeyMgmt, root.keyMgmtWpaPsk, root.securityPsk, password]; + + executeCommand(cmd, result => { + if (result.success) { + loadSavedConnections(() => {}); + activateConnection(ssid, callback); + } else { + const hasDuplicateWarning = result.error && (result.error.includes("another connection with the name") || result.error.includes("Reference the connection by its uuid")); + + if (hasDuplicateWarning || (result.exitCode > 0 && result.exitCode < 10)) { + loadSavedConnections(() => {}); + activateConnection(ssid, callback); + } else { + console.warn(lc, "Connection profile creation failed, trying fallback..."); + let fallbackCmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "connect", ssid, root.connectionParamPassword, password]; + executeCommand(fallbackCmd, fallbackResult => { + if (callback) + callback(fallbackResult); + }); + } + } + }); + }); + } + + function deduplicateNetworks(networks: list): list { + if (!networks || networks.length === 0) { + return []; + } + + const networkMap = new Map(); + for (const network of networks) { + const existing = networkMap.get(network.ssid); + if (!existing) { + networkMap.set(network.ssid, network); + } else { + if (network.active && !existing.active) { + networkMap.set(network.ssid, network); + } else if (!network.active && !existing.active) { + if (network.strength > existing.strength) { + networkMap.set(network.ssid, network); + } + } + } + } + + return Array.from(networkMap.values()); + } + + function detectPasswordRequired(error: string): bool { + if (!error || error.length === 0) { + return false; + } + + return (error.includes("Secrets were required") || error.includes("Secrets were required, but not provided") || error.includes("No secrets provided") || error.includes("802-11-wireless-security.psk") || error.includes("password for") || (error.includes("password") && !error.includes("Connection activated") && !error.includes("successfully")) || (error.includes("Secrets") && !error.includes("Connection activated") && !error.includes("successfully")) || (error.includes("802.11") && !error.includes("Connection activated") && !error.includes("successfully"))) && !error.includes("Connection activated") && !error.includes("successfully"); + } + + function disconnect(interfaceName: string, callback: var): void { + if (interfaceName && interfaceName.length > 0) { + executeCommand([root.nmcliCommandDevice, "disconnect", interfaceName], result => { + if (callback) + callback(result.success ? result.output : ""); + }); + } else { + executeCommand([root.nmcliCommandDevice, "disconnect", root.deviceTypeWifi], result => { + if (callback) + callback(result.success ? result.output : ""); + }); + } + } + + function disconnectEthernet(connectionName: string, callback: var): void { + if (!connectionName || connectionName.length === 0) { + if (callback) + callback({ + success: false, + output: "", + error: "No connection name specified", + exitCode: -1 + }); + return; + } + + executeCommand([root.nmcliCommandConnection, "down", connectionName], result => { + if (result.success) { + root.ethernetDeviceDetails = null; + Qt.callLater(() => { + getEthernetInterfaces(() => {}); + }, 500); + } + if (callback) + callback(result); + }); + } + + function disconnectFromNetwork(): void { + if (active && active.ssid) { + executeCommand([root.nmcliCommandConnection, "down", active.ssid], result => { + if (result.success) { + getNetworks(() => {}); + } + }); + } else { + executeCommand([root.nmcliCommandDevice, "disconnect", root.deviceTypeWifi], result => { + if (result.success) { + getNetworks(() => {}); + } + }); + } + } + + function enableWifi(enabled: bool, callback: var): void { + const cmd = enabled ? "on" : "off"; + executeCommand([root.nmcliCommandRadio, root.nmcliCommandWifi, cmd], result => { + if (result.success) { + getWifiStatus(status => { + root.wifiEnabled = status; + if (callback) + callback(result); + }); + } else { + if (callback) + callback(result); + } + }); + } + + function executeCommand(args: list, callback: var): void { + const proc = commandProc.createObject(root); + proc.cmdArgs = ["nmcli", ...args]; + proc.callback = callback; + + activeProcesses.push(proc); + + proc.processFinished.connect(() => { + const index = activeProcesses.indexOf(proc); + if (index >= 0) { + activeProcesses.splice(index, 1); + } + }); + + Qt.callLater(() => { + proc.exec(proc.cmdArgs); + }); + } + + function findNetwork(ssid: string): var { + return networks.find(n => n.ssid === ssid) ?? null; + } + + function forgetNetwork(ssid: string, callback: var): void { + if (!ssid || ssid.length === 0) { + if (callback) + callback({ + success: false, + output: "", + error: "No SSID specified", + exitCode: -1 + }); + return; + } + + const connectionName = root.savedConnections.find(conn => conn && conn.toLowerCase().trim() === ssid.toLowerCase().trim()) || ssid; + + executeCommand([root.nmcliCommandConnection, "delete", connectionName], result => { + if (result.success) { + Qt.callLater(() => { + loadSavedConnections(() => {}); + }, 500); + } + if (callback) + callback(result); + }); + } + + function getAllInterfaces(callback: var): void { + executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => { + const interfaces = parseDeviceStatusOutput(result.output, "both"); + if (callback) + callback(interfaces); + }); + } + + // Reads whether a saved connection auto-connects. + function getAutoconnect(connectionName: string, callback: var): void { + if (!connectionName || connectionName.length === 0) { + if (callback) + callback(true); + return; + } + executeCommand(["-t", "-f", "connection.autoconnect", root.nmcliCommandConnection, "show", connectionName], result => { + let auto = true; + if (result.success) { + const line = result.output.trim(); + const idx = line.indexOf(":"); + if (idx >= 0) + auto = line.slice(idx + 1).trim() !== "no"; + } + if (callback) + callback(auto); + }); + } + + function getDeviceDetails(interfaceName: string, callback: var): void { + executeCommand([root.nmcliCommandDevice, "show", interfaceName], result => { + if (callback) + callback(result.output); + }); + } + + function getDeviceStatus(callback: var): void { + executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => { + if (callback) + callback(result.output); + }); + } + + function getEthernetDataUsage(interfaceName: string, callback: var): void { + if (!interfaceName || interfaceName.length === 0) { + if (callback) + callback(""); + return; + } + dataUsageProc.iface = interfaceName; + dataUsageProc.cb = callback; + dataUsageProc.command = ["sh", "-c", `cat /sys/class/net/${interfaceName}/statistics/rx_bytes /sys/class/net/${interfaceName}/statistics/tx_bytes 2>/dev/null`]; + dataUsageProc.running = true; + } + + function getEthernetDeviceDetails(interfaceName: string, callback: var): void { + if (!interfaceName || interfaceName.length === 0) { + const activeInterface = root.ethernetInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeInterface && activeInterface.device) { + interfaceName = activeInterface.device; + } else { + if (callback) + callback(null); + return; + } + } + + executeCommand(["device", "show", interfaceName], result => { + if (!result.success || !result.output) { + // Transient failure (e.g. nmcli busy during a toggle). Keep the + // previous details so dependent UI (gateway, IP/DNS) doesn't + // blink out and back. + if (callback) + callback(root.ethernetDeviceDetails); + return; + } + + const details = parseDeviceDetails(result.output, true); + root.ethernetDeviceDetails = details; + if (callback) + callback(details); + }); + } + + function getEthernetInterfaces(callback: var): void { + executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => { + const interfaces = parseDeviceStatusOutput(result.output, root.deviceTypeEthernet); + const applyInterfaces = filtered => { + const devices = filtered.map(iface => ({ + interface: iface.device, + type: iface.type, + state: iface.state, + connection: iface.connection, + connected: isConnectedState(iface.state), + ipAddress: "", + gateway: "", + dns: [], + subnet: "", + macAddress: "", + speed: "" + })); + + root.ethernetInterfaces = filtered; + syncEthernetDevices(devices); + if (callback) + callback(filtered); + }; + + if (interfaces.length === 0) { + applyInterfaces([]); + return; + } + + // NetworkManager reports container/VM veth pairs (Docker, Podman, + // etc.) as type "ethernet" too, so they'd show up here like real + // connections. A physical NIC always has + // /sys/class/net//device; veth/bridge/tun interfaces + // never do, so that's how we tell them apart. + const proc = physicalCheckProc.createObject(root); + proc.callback = result => { + if (!result.success) { + console.warn(lc, `Failed to classify ethernet interfaces (exited: ${result.exitCode}); keeping the unfiltered list.`); + applyInterfaces(interfaces); + return; + } + + const physicalSet = result.output.trim().split("\n").filter(l => l.length > 0); + const filtered = interfaces.filter(iface => physicalSet.includes(iface.device)); + + applyInterfaces(filtered); + }; + + proc.exec(["sh", "-c", 'test -d /sys/class/net || exit 1; for i do [ -e "/sys/class/net/$i/device" ] && printf "%s\\n" "$i"; done; exit 0', "sh", ...interfaces.map(iface => iface.device)]); + }); + } + + // Reads cumulative since-boot byte counters from sysfs for an interface and + // returns a human-readable total via the callback. + // Reads the negotiated link speed (Mbit/s) from sysfs and stores a + // human-readable form in ethernetSpeed. nmcli `device show` doesn't expose + // link speed, so sysfs is the root-free source. + function getEthernetSpeed(interfaceName: string): void { + if (!interfaceName || interfaceName.length === 0) { + root.ethernetSpeed = ""; + return; + } + speedProc.command = ["sh", "-c", `cat /sys/class/net/${interfaceName}/speed 2>/dev/null`]; + speedProc.running = true; + } + + // Reads the IPv4 configuration (method, address, gateway, DNS, autoconnect) + // of a connection profile for the ethernet detail page. + function getIpv4Config(connectionName: string, callback: var): void { + if (!connectionName || connectionName.length === 0) { + if (callback) + callback(null); + return; + } + + executeCommand(["-t", "-f", "ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,ipv4.ignore-auto-dns,connection.autoconnect", root.nmcliCommandConnection, "show", connectionName], result => { + if (!result.success) { + if (callback) + callback(null); + return; + } + + const cfg = { + method: "auto", + address: "", + gateway: "", + dns: "", + ignoreAutoDns: false, + autoconnect: true + }; + + const lines = result.output.trim().split("\n"); + for (const line of lines) { + const idx = line.indexOf(":"); + if (idx < 0) + continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + + if (key === "ipv4.ignore-auto-dns") + cfg.ignoreAutoDns = value === "yes"; + else if (key === "connection.autoconnect") + cfg.autoconnect = value !== "no"; + + if (value === "" || value === "--") + continue; + + if (key === "ipv4.method") + cfg.method = value; + else if (key === "ipv4.addresses") + cfg.address = value.split(",")[0].trim(); + else if (key === "ipv4.gateway") + cfg.gateway = value; + else if (key === "ipv4.dns") + cfg.dns = value.replace(/;\s*$/, "").split(/[;,]/).map(d => d.trim()).filter(d => d.length > 0).join(", "); + } + + // Distinguish "automatic + custom DNS only" from plain DHCP. + if (cfg.method === "auto" && cfg.ignoreAutoDns) + cfg.method = "auto-dns"; + + if (callback) + callback(cfg); + }); + } + + function getNetworks(callback: var): void { + executeCommand(["-g", root.networkDetailFields, "d", "w"], result => { + if (!result.success) { + if (callback) + callback([]); + return; + } + + const allNetworks = parseNetworkOutput(result.output); + const networks = deduplicateNetworks(allNetworks); + const rNetworks = root.networks; + + const newMap = new Map(); + for (const n of networks) + newMap.set(`${n.frequency}:${n.ssid}:${n.bssid}`, n); + + for (let i = rNetworks.length - 1; i >= 0; i--) { + const rn = rNetworks[i]; + const key = `${rn.frequency}:${rn.ssid}:${rn.bssid}`; + if (!newMap.has(key)) { + rNetworks.splice(i, 1); + rn.destroy(); + } + } + + const existingMap = new Map(); + for (const rn of rNetworks) + existingMap.set(`${rn.frequency}:${rn.ssid}:${rn.bssid}`, rn); + + for (const [key, network] of newMap) { + const match = existingMap.get(key); + if (match) { + match.lastIpcObject = network; + } else { + rNetworks.push(apComp.createObject(root, { + lastIpcObject: network + })); + } + } + + if (callback) + callback(root.networks); + checkPendingConnection(); + }); + } + + function getWifiStatus(callback: var): void { + executeCommand([root.nmcliCommandRadio, root.nmcliCommandWifi], result => { + if (result.success) { + const enabled = result.output.trim() === "enabled"; + root.wifiEnabled = enabled; + if (callback) + callback(enabled); + } else { + if (callback) + callback(root.wifiEnabled); + } + }); + } + + function getWirelessDeviceDetails(interfaceName: string, callback: var): void { + if (!interfaceName || interfaceName.length === 0) { + const activeInterface = root.wirelessInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeInterface && activeInterface.device) { + interfaceName = activeInterface.device; + } else { + if (callback) + callback(null); + return; + } + } + + executeCommand(["device", "show", interfaceName], result => { + if (!result.success || !result.output) { + root.wirelessDeviceDetails = null; + if (callback) + callback(null); + return; + } + + const details = parseDeviceDetails(result.output, false); + root.wirelessDeviceDetails = details; + if (callback) + callback(details); + }); + } + + function getWirelessInterfaces(callback: var): void { + executeCommand(["-t", "-f", root.deviceStatusFields, root.nmcliCommandDevice, "status"], result => { + const interfaces = parseDeviceStatusOutput(result.output, root.deviceTypeWifi); + root.wirelessInterfaces = interfaces; + if (callback) + callback(interfaces); + }); + } + + function getWirelessSSIDs(interfaceName: string, callback: var): void { + let cmd = ["-t", "-f", root.networkListFields, root.nmcliCommandDevice, root.nmcliCommandWifi, "list"]; + if (interfaceName && interfaceName.length > 0) { + cmd.push(root.connectionParamIfname, interfaceName); + } + executeCommand(cmd, result => { + if (!result.success) { + if (callback) + callback([]); + return; + } + + const ssids = []; + const lines = result.output.trim().split("\n"); + const seenSSIDs = new Set(); + + for (const line of lines) { + if (!line || line.length === 0) + continue; + + const parts = line.split(":"); + if (parts.length >= 1) { + const ssid = parts[0].trim(); + if (ssid && ssid.length > 0 && !seenSSIDs.has(ssid)) { + seenSSIDs.add(ssid); + const signalStr = parts.length >= 2 ? parts[1].trim() : ""; + const signal = signalStr ? parseInt(signalStr, 10) : 0; + const security = parts.length >= 3 ? parts[2].trim() : ""; + ssids.push({ + ssid: ssid, + signal: signalStr, + signalValue: isNaN(signal) ? 0 : signal, + security: security + }); + } + } + } + + ssids.sort((a, b) => { + return b.signalValue - a.signalValue; + }); + + if (callback) + callback(ssids); + }); + } + + function handlePasswordRequired(proc: var, error: string, output: string, exitCode: int): bool { + if (!proc || !error || error.length === 0) { + return false; + } + + if (!isConnectionCommand(proc.cmdArgs) || !root.pendingConnection || !root.pendingConnection.callback) { + return false; + } + + const needsPassword = detectPasswordRequired(error); + + if (needsPassword && !proc.callbackCalled && root.pendingConnection) { + connectionCheckTimer.stop(); + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + const pending = root.pendingConnection; + root.pendingConnection = null; + proc.callbackCalled = true; + const result = { + success: false, + output: output || "", + error: error, + exitCode: exitCode, + needsPassword: true + }; + if (pending.callback) { + pending.callback(result); + } + if (proc.callback && proc.callback !== pending.callback) { + proc.callback(result); + } + return true; + } + + return false; + } + + function hasSavedProfile(ssid: string): bool { + if (!ssid || ssid.length === 0) { + return false; + } + const ssidLower = ssid.toLowerCase().trim(); + + if (root.active && root.active.ssid) { + const activeSsidLower = root.active.ssid.toLowerCase().trim(); + if (activeSsidLower === ssidLower) { + return true; + } + } + + const hasSsid = root.savedConnectionSsids.some(savedSsid => savedSsid && savedSsid.toLowerCase().trim() === ssidLower); + + if (hasSsid) { + return true; + } + + const hasConnectionName = root.savedConnections.some(connName => connName && connName.toLowerCase().trim() === ssidLower); + + return hasConnectionName; + } + + function isConnectedState(state: string): bool { + if (!state || state.length === 0) { + return false; + } + + return state === "100 (connected)" || state === "connected" || state.startsWith("connected"); + } + + function isConnectingState(state: string): bool { + return !!state && state.startsWith("connecting"); + } + + function isConnectionCommand(command: list): bool { + if (!command || command.length === 0) { + return false; + } + + return command.includes(root.nmcliCommandWifi) || command.includes(root.nmcliCommandConnection); + } + + function isInterfaceConnected(interfaceName: string, callback: var): void { + executeCommand([root.nmcliCommandDevice, "status"], result => { + const lines = result.output.trim().split("\n"); + for (const line of lines) { + const parts = line.split(/\s+/); + if (parts.length >= 3 && parts[0] === interfaceName) { + const connected = isConnectedState(parts[2]); + if (callback) + callback(connected); + return; + } + } + if (callback) + callback(false); + }); + } + + function loadSavedConnections(callback: var): void { + executeCommand(["-t", "-f", root.connectionListFields, root.nmcliCommandConnection, "show"], result => { + if (!result.success) { + root.savedConnections = []; + root.savedConnectionSsids = []; + root.savedConnectionSecurity = {}; + if (callback) + callback([]); + return; + } + + parseConnectionList(result.output, callback); + }); + } + + function parseConnectionList(output: string, callback: var): void { + const lines = output.trim().split("\n").filter(line => line.length > 0); + const wifiConnections = []; + const connections = []; + + for (const line of lines) { + const parts = line.split(":"); + if (parts.length >= 2) { + const name = parts[0]; + const type = parts[1]; + connections.push(name); + + if (type === root.connectionTypeWireless) { + wifiConnections.push(name); + } + } + } + + root.savedConnections = connections; + + root.savedConnectionSecurity = {}; + + if (wifiConnections.length > 0) { + root.wifiConnectionQueue = wifiConnections; + root.currentSsidQueryIndex = 0; + root.savedConnectionSsids = []; + queryNextSsid(callback); + } else { + root.savedConnectionSsids = []; + root.wifiConnectionQueue = []; + if (callback) + callback(root.savedConnectionSsids); + } + } + + function parseDeviceDetails(output: string, isEthernet: bool): var { + const details = { + ipAddress: "", + gateway: "", + dns: [], + subnet: "", + macAddress: "", + speed: "" + }; + + if (!output || output.length === 0) { + return details; + } + + const lines = output.trim().split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(":"); + if (parts.length >= 2) { + const key = parts[0].trim(); + const value = parts.slice(1).join(":").trim(); + + if (key.startsWith("IP4.ADDRESS")) { + const ipParts = value.split("/"); + details.ipAddress = ipParts[0] || ""; + if (ipParts[1]) { + details.subnet = cidrToSubnetMask(ipParts[1]); + } else { + details.subnet = ""; + } + } else if (key === "IP4.GATEWAY") { + if (value !== "--") { + details.gateway = value; + } + } else if (key.startsWith("IP4.DNS")) { + if (value !== "--" && value.length > 0) { + details.dns.push(value); + } + } else if (key === "GENERAL.HWADDR") { + details.macAddress = value; + } + } + } + + return details; + } + + function parseDeviceStatusOutput(output: string, filterType: string): list { + if (!output || output.length === 0) { + return []; + } + + const interfaces = []; + const lines = output.trim().split("\n"); + + for (const line of lines) { + const parts = line.split(":"); + if (parts.length >= 2) { + const deviceType = parts[1]; + let shouldInclude = false; + + if (filterType === root.deviceTypeWifi && deviceType === root.deviceTypeWifi) { + shouldInclude = true; + } else if (filterType === root.deviceTypeEthernet && deviceType === root.deviceTypeEthernet) { + shouldInclude = true; + } else if (filterType === "both" && (deviceType === root.deviceTypeWifi || deviceType === root.deviceTypeEthernet)) { + shouldInclude = true; + } + + if (shouldInclude) { + interfaces.push({ + device: parts[0] || "", + type: parts[1] || "", + state: parts[2] || "", + connection: parts[3] || "" + }); + } + } + } + + return interfaces; + } + + function parseNetworkOutput(output: string): list { + if (!output || output.length === 0) { + return []; + } + + const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED"; + const rep = new RegExp("\\\\:", "g"); + const rep2 = new RegExp(PLACEHOLDER, "g"); + + const allNetworks = output.trim().split("\n").filter(line => line && line.length > 0).map(n => { + const net = n.replace(rep, PLACEHOLDER).split(":"); + return { + active: net[0] === "yes", + strength: parseInt(net[1] || "0", 10) || 0, + frequency: parseInt(net[2] || "0", 10) || 0, + ssid: (net[3]?.replace(rep2, ":") ?? "").trim(), + bssid: (net[4]?.replace(rep2, ":") ?? "").trim(), + security: (net[5] ?? "").trim() + }; + }).filter(n => n.ssid && n.ssid.length > 0); + + return allNetworks; + } + + function processSsidOutput(output: string): void { + const ssidPrefix = "802-11-wireless.ssid:"; + const keyMgmtPrefix = `${root.securityKeyMgmt}:`; + + let ssid = ""; + let keyMgmt = ""; + for (const line of output.trim().split("\n")) { + if (line.startsWith(ssidPrefix)) + ssid = line.substring(ssidPrefix.length).trim(); + else if (line.startsWith(keyMgmtPrefix)) + keyMgmt = line.substring(keyMgmtPrefix.length).trim(); + } + + if (!ssid || ssid.length === 0) + return; + + const ssidLower = ssid.toLowerCase(); + + const exists = root.savedConnectionSsids.some(s => s && s.toLowerCase() === ssidLower); + if (!exists) { + const newList = root.savedConnectionSsids.slice(); + newList.push(ssid); + root.savedConnectionSsids = newList; + } + + const security = Object.assign({}, root.savedConnectionSecurity); + security[ssidLower] = keyMgmt; + root.savedConnectionSecurity = security; + } + + function queryNextSsid(callback: var): void { + if (root.currentSsidQueryIndex < root.wifiConnectionQueue.length) { + const connectionName = root.wifiConnectionQueue[root.currentSsidQueryIndex]; + root.currentSsidQueryIndex++; + + executeCommand(["-t", "-f", `${root.wirelessSsidField},${root.securityKeyMgmt}`, root.nmcliCommandConnection, "show", connectionName], result => { + if (result.success) { + processSsidOutput(result.output); + } + queryNextSsid(callback); + }); + } else { + root.wifiConnectionQueue = []; + root.currentSsidQueryIndex = 0; + if (callback) + callback(root.savedConnectionSsids); + } + } + + function refreshOnConnectionChange(): void { + getNetworks(networks => { + const newActive = root.active; + + if (newActive && newActive.active) { + Qt.callLater(() => { + if (root.wirelessInterfaces.length > 0) { + const activeWireless = root.wirelessInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeWireless && activeWireless.device) { + getWirelessDeviceDetails(activeWireless.device, () => {}); + } + } + + if (root.ethernetInterfaces.length > 0) { + const activeEthernet = root.ethernetInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeEthernet && activeEthernet.device) { + getEthernetDeviceDetails(activeEthernet.device, () => {}); + } + } + }, 500); + } else { + root.wirelessDeviceDetails = null; + root.ethernetDeviceDetails = null; + } + + getWirelessInterfaces(() => {}); + getEthernetInterfaces(() => { + if (root.activeEthernet && root.activeEthernet.connected) { + Qt.callLater(() => { + getEthernetDeviceDetails(root.activeEthernet.iface, () => {}); + }, 500); + } + }); + }); + } + + function refreshStatus(callback: var): void { + getDeviceStatus(output => { + const lines = output.trim().split("\n"); + let connected = false; + let activeIf = ""; + let activeConn = ""; + + for (const line of lines) { + const parts = line.split(":"); + if (parts.length >= 4) { + const state = parts[2] || ""; + if (isConnectedState(state)) { + connected = true; + activeIf = parts[0] || ""; + activeConn = parts[3] || ""; + break; + } + } + } + + root.isConnected = connected; + root.activeInterface = activeIf; + root.activeConnection = activeConn; + + if (callback) + callback({ + connected, + interface: activeIf, + connection: activeConn + }); + }); + } + + function rescanWifi(): void { + rescanProc.running = true; + } + + // Cached security label for a saved SSID, or "" if unknown (e.g. not loaded). + function savedSecurityFor(ssid: string): string { + if (!ssid || ssid.length === 0) + return ""; + return root.savedConnectionSecurity[ssid.toLowerCase().trim()] || ""; + } + + function scanWirelessNetworks(interfaceName: string, callback: var): void { + let cmd = [root.nmcliCommandDevice, root.nmcliCommandWifi, "rescan"]; + if (interfaceName && interfaceName.length > 0) { + cmd.push(root.connectionParamIfname, interfaceName); + } + executeCommand(cmd, result => { + if (callback) { + callback(result); + } + }); + } + + function securityLabel(keyMgmt: string): string { + switch ((keyMgmt || "").trim().toLowerCase()) { + case "": + case "none": + return Tr.trCtx("Open", "wifi security type"); + case "sae": + return "WPA3"; + case "wpa-psk": + return "WPA2"; + case "wpa-eap": + case "wpa-eap-suite-b-192": + return Tr.tr("Enterprise"); + case "owe": + return Tr.tr("Enhanced Open"); + case "ieee8021x": + return "802.1X"; + default: + return keyMgmt.trim(); + } + } + + // Toggles auto-connect for a saved connection. When turned OFF, also makes + // NetworkManager ask for the password on the next manual connect instead of + // silently reusing the stored one (psk-flags 2 = "not saved, always ask"); + // turning it back ON restores psk-flags 0 so the next password is saved. + function setAutoconnect(connectionName: string, enabled: bool, callback: var): void { + if (!connectionName || connectionName.length === 0) { + if (callback) + callback({ + success: false, + output: "", + error: "No connection specified", + exitCode: -1 + }); + return; + } + + let cmd = [root.nmcliCommandConnection, "modify", connectionName, "connection.autoconnect", enabled ? "yes" : "no"]; + + if (enabled) { + cmd.push("802-11-wireless-security.psk-flags", "0"); + } else { + cmd.push("802-11-wireless-security.psk-flags", "2"); + cmd.push("802-11-wireless-security.psk", ""); + } + + executeCommand(cmd, result => { + // For open networks the security fields don't exist; nmcli then + // errors. Retry with just the autoconnect change so it still works. + if (!result.success && result.error && (result.error.includes("802-11-wireless-security") || result.error.includes("is not a valid property") || result.error.includes("Error: invalid"))) { + executeCommand([root.nmcliCommandConnection, "modify", connectionName, "connection.autoconnect", enabled ? "yes" : "no"], retryResult => { + if (callback) + callback(retryResult); + }); + return; + } + if (callback) + callback(result); + }); + } + + // Writes an IPv4 configuration to a connection profile and reactivates it so + // the change takes effect immediately. + function setIpv4Config(connectionName: string, config: var, callback: var): void { + if (!connectionName || connectionName.length === 0) { + if (callback) + callback({ + success: false, + output: "", + error: "No connection specified", + exitCode: -1 + }); + return; + } + + const dnsList = (config.dns ?? "").split(",").map(d => d.trim()).filter(d => d.length > 0).join(" "); + let cmd = [root.nmcliCommandConnection, "modify", connectionName]; + + if (config.method === "manual") { + cmd.push("ipv4.method", "manual"); + cmd.push("ipv4.addresses", config.address ?? ""); + cmd.push("ipv4.gateway", config.gateway ?? ""); + cmd.push("ipv4.dns", dnsList); + cmd.push("ipv4.ignore-auto-dns", "yes"); + } else if (config.method === "auto-dns") { + // DHCP addressing, custom DNS only. + cmd.push("ipv4.method", "auto"); + cmd.push("ipv4.addresses", ""); + cmd.push("ipv4.gateway", ""); + cmd.push("ipv4.dns", dnsList); + cmd.push("ipv4.ignore-auto-dns", "yes"); + } else { + // Full DHCP: clear manual fields and re-enable auto DNS. + cmd.push("ipv4.method", "auto"); + cmd.push("ipv4.addresses", ""); + cmd.push("ipv4.gateway", ""); + cmd.push("ipv4.dns", ""); + cmd.push("ipv4.ignore-auto-dns", "no"); + } + + executeCommand(cmd, result => { + if (!result.success) { + if (callback) + callback(result); + return; + } + // Reactivate so changes take effect immediately. + executeCommand([root.nmcliCommandConnection, "up", connectionName], upResult => { + Qt.callLater(() => { + refreshOnConnectionChange(); + }); + if (callback) + callback(upResult); + }); + }); + } + + // Sync a list of ethernet devices to the existing device list. Same logic as getNetworks + function syncEthernetDevices(devices: list): void { + const rDevices = root.ethernetDevices; + + const newMap = new Map(); + for (const d of devices) + newMap.set(d.interface, d); + + for (let i = rDevices.length - 1; i >= 0; i--) { + if (!newMap.has(rDevices[i].iface)) { + const removed = rDevices.splice(i, 1)[0]; + removed.destroy(); + } + } + + const existingMap = new Map(); + for (const rd of rDevices) + existingMap.set(rd.iface, rd); + + for (const [iface, data] of newMap) { + const match = existingMap.get(iface); + if (match) + match.lastIpcObject = data; + else + rDevices.push(ethComp.createObject(root, { + lastIpcObject: data + })); + } + } + + function toggleWifi(callback: var): void { + const newState = !root.wifiEnabled; + enableWifi(newState, callback); + } + + Component.onCompleted: { + getWifiStatus(() => {}); + getNetworks(() => {}); + loadSavedConnections(() => {}); + getEthernetInterfaces(() => {}); + + Qt.callLater(() => { + if (root.wirelessInterfaces.length > 0) { + const activeWireless = root.wirelessInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeWireless && activeWireless.device) { + getWirelessDeviceDetails(activeWireless.device, () => {}); + } + } + + if (root.ethernetInterfaces.length > 0) { + const activeEthernet = root.ethernetInterfaces.find(iface => { + return isConnectedState(iface.state); + }); + if (activeEthernet && activeEthernet.device) { + getEthernetDeviceDetails(activeEthernet.device, () => {}); + } + } + }, 2000); + } + + Component { + id: commandProc + + CommandProcess { + } + } + + Component { + id: apComp + + AccessPoint { + } + } + + Component { + id: ethComp + + EthernetDevice { + } + } + + Component { + id: physicalCheckProc + + Process { + id: proc + + property var callback: null + + stderr: StdioCollector { + id: stderrCollector + } + stdout: StdioCollector { + id: stdoutCollector + } + + onExited: code => { + Qt.callLater(() => { + const callback = proc.callback; + const result = { + success: code === 0, + output: stdoutCollector.text ?? "", + error: stderrCollector.text ?? "", + exitCode: code + }; + + proc.destroy(); + callback?.(result); + }); + } + } + } + + Timer { + id: connectionCheckTimer + + interval: 4000 + + onTriggered: { + if (root.pendingConnection) { + const connected = root.active && root.active.ssid === root.pendingConnection.ssid; + + if (!connected && root.pendingConnection.callback) { + let foundPasswordError = false; + for (let i = 0; i < root.activeProcesses.length; i++) { + const proc = root.activeProcesses[i]; + if (proc && proc.stderr && proc.stderr.text) { + const error = proc.stderr.text.trim(); + if (error && error.length > 0) { + if (root.isConnectionCommand(proc.cmdArgs)) { + const needsPassword = root.detectPasswordRequired(error); + + if (needsPassword && !proc.callbackCalled && root.pendingConnection) { + const pending = root.pendingConnection; + root.pendingConnection = null; + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + proc.callbackCalled = true; + const result = { + success: false, + output: (proc.stdout && proc.stdout.text) ? proc.stdout.text : "", + error: error, + exitCode: -1, + needsPassword: true + }; + if (pending.callback) { + pending.callback(result); + } + if (proc.callback && proc.callback !== pending.callback) { + proc.callback(result); + } + foundPasswordError = true; + break; + } + } + } + } + } + + if (!foundPasswordError) { + const pending = root.pendingConnection; + const failedSsid = pending.ssid; + root.pendingConnection = null; + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + root.connectionFailed(failedSsid); + pending.callback({ + success: false, + output: "", + error: "Connection timeout", + exitCode: -1, + needsPassword: false + }); + } + } else if (connected) { + root.pendingConnection = null; + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + } + } + } + } + + Timer { + id: immediateCheckTimer + + property int checkCount: 0 + + interval: 500 + repeat: true + triggeredOnStart: false + + onTriggered: { + if (root.pendingConnection) { + checkCount++; + const connected = root.active && root.active.ssid === root.pendingConnection.ssid; + + if (connected) { + connectionCheckTimer.stop(); + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + if (root.pendingConnection.callback) { + root.pendingConnection.callback({ + success: true, + output: "Connected", + error: "", + exitCode: 0 + }); + } + root.pendingConnection = null; + } else { + for (let i = 0; i < root.activeProcesses.length; i++) { + const proc = root.activeProcesses[i]; + if (proc && proc.stderr && proc.stderr.text) { + const error = proc.stderr.text.trim(); + if (error && error.length > 0) { + if (root.isConnectionCommand(proc.cmdArgs)) { + const needsPassword = root.detectPasswordRequired(error); + + if (needsPassword && !proc.callbackCalled && root.pendingConnection && root.pendingConnection.callback) { + connectionCheckTimer.stop(); + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + const pending = root.pendingConnection; + root.pendingConnection = null; + proc.callbackCalled = true; + const result = { + success: false, + output: (proc.stdout && proc.stdout.text) ? proc.stdout.text : "", + error: error, + exitCode: -1, + needsPassword: true + }; + if (pending.callback) { + pending.callback(result); + } + if (proc.callback && proc.callback !== pending.callback) { + proc.callback(result); + } + return; + } + } + } + } + } + + if (checkCount >= 6) { + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + } + } + } else { + immediateCheckTimer.stop(); + immediateCheckTimer.checkCount = 0; + } + } + } + + Process { + id: dataUsageProc + + property var cb + property string iface + + stdout: StdioCollector { + onStreamFinished: { + const nums = text.trim().split("\n").map(n => parseInt(n.trim(), 10)).filter(n => !isNaN(n)); + if (nums.length < 2) { + if (dataUsageProc.cb) + dataUsageProc.cb(""); + return; + } + const human = Units.formatBytes(nums[0] + nums[1]); + root.ethernetDataUsage = human; + if (dataUsageProc.cb) + dataUsageProc.cb(human); + } + } + } + + Process { + id: speedProc + + stdout: StdioCollector { + onStreamFinished: { + const mbit = parseInt(text.trim(), 10); + // Disconnected/virtual interfaces report -1 or nothing. + if (isNaN(mbit) || mbit <= 0) { + root.ethernetSpeed = ""; + } else if (mbit >= 1000) { + const gbps = mbit / 1000; + // TRANSLATORS: %1 = a number + root.ethernetSpeed = Tr.tr("%1 Gbps").arg(Number.isInteger(gbps) ? gbps : gbps.toFixed(1)); + } else { + // TRANSLATORS: %1 = a number + root.ethernetSpeed = Tr.tr("%1 Mbps").arg(mbit); + } + } + } + } + + Process { + id: rescanProc + + command: ["nmcli", "dev", root.nmcliCommandWifi, "list", "--rescan", "yes"] + + onExited: root.getNetworks() + } + + Process { + id: monitorProc + + command: ["nmcli", "monitor"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + running: true + + stdout: SplitParser { + onRead: root.refreshOnConnectionChange() + } + + onExited: monitorRestartTimer.start() + } + + Timer { + id: monitorRestartTimer + + interval: 2000 + + onTriggered: { + monitorProc.running = true; + } + } + + LoggingCategory { + id: lc + + defaultLogLevel: LoggingCategory.Info + name: "caelestia.qml.services.nmcli" + } + + component AccessPoint: QtObject { + readonly property bool active: lastIpcObject.active + readonly property string bssid: lastIpcObject.bssid + readonly property int frequency: lastIpcObject.frequency + readonly property bool isSecure: security.length > 0 + required property var lastIpcObject + readonly property string security: lastIpcObject.security + readonly property string ssid: lastIpcObject.ssid + readonly property int strength: lastIpcObject.strength + } + component CommandProcess: Process { + id: proc + + property var callback: null + property bool callbackCalled: false + property list cmdArgs: [] + property int exitCode: 0 + + signal processFinished + + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + + stderr: StdioCollector { + id: stderrCollector + + onStreamFinished: { + const error = text.trim(); + if (error && error.length > 0) { + const output = (stdoutCollector && stdoutCollector.text) ? stdoutCollector.text : ""; + root.handlePasswordRequired(proc, error, output, -1); + } + } + } + stdout: StdioCollector { + id: stdoutCollector + } + + onExited: code => { + exitCode = code; + + Qt.callLater(() => { + if (callbackCalled) { + processFinished(); + return; + } + + if (proc.callback) { + const output = (stdoutCollector && stdoutCollector.text) ? stdoutCollector.text : ""; + const error = (stderrCollector && stderrCollector.text) ? stderrCollector.text : ""; + const success = exitCode === 0; + const cmdIsConnection = isConnectionCommand(proc.cmdArgs); + + if (root.handlePasswordRequired(proc, error, output, exitCode)) { + processFinished(); + return; + } + + const needsPassword = cmdIsConnection && root.detectPasswordRequired(error); + + if (!success && cmdIsConnection && root.pendingConnection) { + const failedSsid = root.pendingConnection.ssid; + root.connectionFailed(failedSsid); + } + + callbackCalled = true; + callback({ + success: success, + output: output, + error: error, + exitCode: proc.exitCode, + needsPassword: needsPassword || false + }); + processFinished(); + } else { + processFinished(); + } + }); + } + } + component EthernetDevice: QtObject { + readonly property bool connected: lastIpcObject.connected + readonly property string connection: lastIpcObject.connection + readonly property var dns: lastIpcObject.dns ?? [] + readonly property string gateway: lastIpcObject.gateway ?? "" + readonly property string iface: lastIpcObject.interface + readonly property string ipAddress: lastIpcObject.ipAddress ?? "" + required property var lastIpcObject + readonly property string macAddress: lastIpcObject.macAddress ?? "" + readonly property string speed: lastIpcObject.speed ?? "" + readonly property string state: lastIpcObject.state + readonly property string subnet: lastIpcObject.subnet ?? "" + readonly property string type: lastIpcObject.type + } +}