diff --git a/src/share/sleex/modules/common/Config.qml b/src/share/sleex/modules/common/Config.qml index eb1ff5d1..5aff7d40 100644 --- a/src/share/sleex/modules/common/Config.qml +++ b/src/share/sleex/modules/common/Config.qml @@ -216,6 +216,10 @@ Singleton { property JsonObject networking: JsonObject { property string userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" + property bool connectionDetails: true + property bool sensitiveNetworkInfo: false + property bool dnsSwitch: false + property string dnsProvider: "" } property JsonObject osd: JsonObject { diff --git a/src/share/sleex/modules/settings/Wifi.qml b/src/share/sleex/modules/settings/Wifi.qml index 9a6cc431..abaee1f8 100644 --- a/src/share/sleex/modules/settings/Wifi.qml +++ b/src/share/sleex/modules/settings/Wifi.qml @@ -4,602 +4,1641 @@ import QtQuick.Layouts import Quickshell import Quickshell.Io import Quickshell.Widgets -import Quickshell.Bluetooth import qs.services +import qs.services as Services import qs.modules.common import qs.modules.common.widgets import Sleex.Services +// qs.services imported both plain (bare Config) and as Services (Services.Network, +// to avoid clashing with the bare C++ Network singleton below) ContentPage { id: root forceSingleColumn: true - // forceWidth: true - - // Connection error tracking - property string lastConnectionError: "" - property string errorSsid: "" - property bool showConnectionError: false - - // UI refresh trigger - property int refreshTrigger: 0 - - // Network connection result handlers - Connections { - target: Network - - function onConnectionSucceeded(ssid) { - // Clear any previous errors - root.showConnectionError = false; - root.lastConnectionError = ""; - root.errorSsid = ""; - - // Refresh UI to show updated connection state - root.refreshTrigger++; - - Qt.callLater(function() { - if (Network.updateNetworks) { - Network.updateNetworks(); - } - if (Network.updateActiveConnection) { - Network.updateActiveConnection(); - } - // Force ScriptModel re-evaluation - root.refreshTrigger++; - }); + + property bool showSensitiveInfo: false + property bool showConnectionDetails: true + property bool networkSearchVisible: false + property string searchText: "" + + property bool customDnsEnabled: false + property string customDnsProviderId: "cloudflare" + + readonly property var filteredDetailItems: { + var arr = []; + for (var i = 0; i < Services.Network.detailItems.length; ++i) { + const item = Services.Network.detailItems[i]; + if (item.wifiOnly && Services.Network.activeNetwork === null) continue; + if (!item.isSensitive || root.showSensitiveInfo) + arr.push(item); } - - function onConnectionFailed(ssid, error) { - root.lastConnectionError = error; - root.errorSsid = ssid; - root.showConnectionError = true; - - // Auto-hide error after 5 seconds - errorTimer.restart(); - - // Refresh UI to show current state - root.refreshTrigger++; - - Qt.callLater(function() { - if (Network.updateNetworks) { - Network.updateNetworks(); - } - if (Network.updateActiveConnection) { - Network.updateActiveConnection(); - } - // Force ScriptModel re-evaluation - root.refreshTrigger++; - }); + return arr; + } + + function _syncViewTogglesFromConfig() { + root.showSensitiveInfo = Config.options.networking.sensitiveNetworkInfo || false; + root.showConnectionDetails = Config.options.networking.connectionDetails !== undefined + ? Config.options.networking.connectionDetails : true; + const savedProvider = Config.options.networking.dnsProvider; + if (savedProvider) root.customDnsProviderId = savedProvider; + root.customDnsEnabled = Config.options.networking.dnsSwitch || false; + } + + Component.onCompleted: root._syncViewTogglesFromConfig() + + // Re-sync on every Config reload since load order relative to Config isn't guaranteed + Connections { + target: Config + function onIsReloadingChanged() { + if (!Config.isReloading) root._syncViewTogglesFromConfig(); } - + } + + Connections { + target: Network + function onPasswordRequired(ssid) { - // Security type changed - expand the network for password input for (let i = 0; i < networkRepeater.count; i++) { - let item = networkRepeater.itemAt(i); - if (item && item.modelData && item.modelData.ssid === ssid) { + const item = networkRepeater.itemAt(i); + if (item?.modelData?.ssid === ssid) { item.expanded = true; break; } } - - // Also refresh the network list - root.refreshTrigger++; - } - } - - // Timer to auto-hide connection errors - Timer { - id: errorTimer - interval: 5000 - onTriggered: { - root.showConnectionError = false; + Services.Network.bumpRefresh(); } } - - - // Rectangle { - // Layout.fillWidth: true - // height: warnChildren.height + 40 - // color: "#40FF9800" - // radius: 6 - - // RowLayout { - // id: warnChildren - // anchors.fill: parent - // anchors.margins: 10 - - // Label { - // text: "🚧" - // font.pixelSize: 16 // Slightly smaller icon - // Layout.alignment: Qt.AlignVCenter - // rightPadding: 6 - // } - - // Label { - // Layout.fillWidth: true - // Layout.alignment: Qt.AlignVCenter - // text: "WORK IN PROGRESS: This module is incomplete. You can connect and disconnect to known devices, nothing else." - // font.pixelSize: 12 - // wrapMode: Text.WordWrap - // textFormat: Text.RichText - // color: "white" - // } - // } - // } - ContentSection { title: "Wifi settings" icon: "network_wifi" - RowLayout { - spacing: 10 - uniformCellSizes: true + ColumnLayout { + Layout.fillWidth: true + spacing: 18 ConfigSwitch { text: "Enabled" - checked: Network.wifiEnabled || false + checked: Services.Network.wifiEnabled + onClicked: Network.toggleWifi() + StyledToolTip { + text: Services.Network.wifiEnabled ? "Click to disable WiFi" : "Click to enable WiFi" + } + } + + ConfigSwitch { + text: "Connection Details" + checked: root.showConnectionDetails + onClicked: { + root.showConnectionDetails = !root.showConnectionDetails; + try { + Config.setNestedValue("networking.connectionDetails", root.showConnectionDetails); + } catch (e) { + console.log("[Wifi] Failed to persist connectionDetails:", e); + } + } + StyledToolTip { + text: root.showConnectionDetails + ? "Hide Connection Details" + : "Show Connection Details" + } + } + + ConfigSwitch { + text: "Sensitive Info" + checked: root.showSensitiveInfo onClicked: { - const newState = !checked; - // Toggle WiFi state - Network.toggleWifi(); + root.showSensitiveInfo = !root.showSensitiveInfo; + try { + Config.setNestedValue("networking.sensitiveNetworkInfo", root.showSensitiveInfo); + } catch (e) { + console.log("[Wifi] Failed to persist sensitiveNetworkInfo:", e); + } } StyledToolTip { - text: Network.wifiEnabled ? "Click to disable WiFi" : "Click to enable WiFi" + text: root.showSensitiveInfo + ? "Hide sensitive network details" + : "Show sensitive network details" } } } } - RowLayout { - spacing: 10 - - StyledText { - text: { - const networks = Network.networks; - let available = qsTr("%1 network%2 available").arg(networks.length).arg(networks.length === 1 ? "" : "s"); - const connected = networks.filter(n => n.active).length; - if (connected > 0) - available += qsTr(" (%1 connected)").arg(connected); - return available; - } - color: Appearance.colors.colOnLayer0 - font.pixelSize: Appearance.font.pixelSize.huge + Item { + Layout.fillWidth: true + readonly property real targetHeight: Services.Network.hasActiveConnection && root.showConnectionDetails + ? healthDashboard.implicitHeight : 0 + height: targetHeight + implicitHeight: targetHeight + clip: true + + Behavior on height { + NumberAnimation { duration: 180; easing.type: Easing.InOutQuad } } - RippleButton { - id: discoverBtn + ContentSection { + id: healthDashboard + width: parent.width + visible: Services.Network.hasActiveConnection && root.showConnectionDetails + title: "Connection Details" + icon: "monitoring" + + ColumnLayout { + Layout.fillWidth: true + spacing: 16 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + MaterialSymbol { + id: connectionTypeIcon + Layout.alignment: Qt.AlignVCenter + text: Services.Network.activeConnectionType.includes("wireless") ? "wifi" : "settings_ethernet" + font.pixelSize: Appearance.font.pixelSize.title + color: Appearance.m3colors.m3primary + } - visible: Network.wifiEnabled || false + ColumnLayout { + spacing: 2 - contentItem: Rectangle { - id: discoverBtnBody - radius: Appearance.rounding.full - color: (Network.scanning || false) ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 - implicitWidth: height + StyledText { + text: Services.Network.activeNetwork?.ssid || (Services.Network.hasWiredConnection ? "Wired Connection" : "") + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 500 + color: Appearance.m3colors.m3primary + } + StyledText { + text: "Connected" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + } - MaterialSymbol { - id: scanIcon + Item { Layout.fillWidth: true } + + RippleButton { + id: speedTestHeaderBtn + + contentItem: Rectangle { + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + implicitWidth: height + + MaterialSymbol { + anchors.centerIn: parent + text: "speed" + color: Appearance.m3colors.m3onSecondaryContainer + fill: Services.Network.speedTestRunning ? 1 : 0 + + SequentialAnimation on opacity { + running: Services.Network.speedTestRunning && root.showConnectionDetails + loops: Animation.Infinite + NumberAnimation { from: 1; to: 0.4; duration: 550; easing.type: Easing.InOutQuad } + NumberAnimation { from: 0.4; to: 1; duration: 550; easing.type: Easing.InOutQuad } + } + } + } + + MouseArea { + id: speedTestHeaderArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Services.Network.speedTestRunning ? Qt.ArrowCursor : Qt.PointingHandCursor + enabled: !Services.Network.speedTestRunning + onClicked: Services.Network.startSpeedTest() + + StyledToolTip { + extraVisibleCondition: speedTestHeaderArea.containsMouse + text: Services.Network.speedTestRunning ? "Testing…" : "Perform a network speed test\nUses the Cloudflare provider" + } + } + } + + RippleButton { + id: refreshInfoBtn + + contentItem: Rectangle { + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + implicitWidth: height + + MaterialSymbol { + anchors.centerIn: parent + text: "refresh" + color: Appearance.m3colors.m3onSecondaryContainer + fill: Services.Network.fetchingNetworkInfo ? 1 : 0 + + RotationAnimation on rotation { + running: Services.Network.fetchingNetworkInfo && root.showConnectionDetails + loops: Animation.Infinite + from: 0; to: 360; duration: 900 + } + } + } - anchors.centerIn: parent - text: "refresh" - color: (Network.scanning || false) ? Appearance.m3colors.m3onSecondary : Appearance.m3colors.m3onSecondaryContainer - fill: (Network.scanning || false) ? 1 : 0 + MouseArea { + id: refreshInfoArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Services.Network.fetchNetworkInfo() + + StyledToolTip { + extraVisibleCondition: refreshInfoArea.containsMouse + text: "Refresh connection info" + } + } + } } - } - MouseArea { - id: discoverArea - anchors.fill: parent - hoverEnabled: true - onClicked: Network.rescanWifi() + Rectangle { + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + } - StyledToolTip { - extraVisibleCondition: discoverArea.containsMouse - text: "Discover new networks" + StyledText { + Layout.fillWidth: true + visible: Services.Network.netInfoError !== "" + text: Services.Network.netInfoError + color: Appearance.m3colors.m3error + font.pixelSize: Appearance.font.pixelSize.small + wrapMode: Text.WordWrap } - } - } - } - ContentSection { + RowLayout { + Layout.fillWidth: true + spacing: 12 + height: 100 + + Rectangle { + visible: Services.Network.activeNetwork !== null + Layout.fillWidth: true + Layout.fillHeight: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + border.width: 1 + border.color: Appearance.colors.colOutlineVariant + + ColumnLayout { + anchors.centerIn: parent + spacing: 6 + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: "signal_cellular_alt" + font.pixelSize: 24 + color: Appearance.m3colors.m3primary + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Signal Strength" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + Rectangle { + Layout.preferredWidth: 80 + Layout.preferredHeight: 4 + radius: 2 + color: Appearance.colors.colOutlineVariant + Layout.alignment: Qt.AlignHCenter - ColumnLayout { - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - visible: !(Network.wifiEnabled || false) - spacing: 10 - - MaterialSymbol { - Layout.alignment: Qt.AlignHCenter - text: "signal_wifi_off" - font.pixelSize: 48 - color: Appearance.colors.colOnLayer1 - } + Rectangle { + width: parent.width * Math.min(Services.Network.activeNetwork?.strength ?? 0, 100) / 100 + height: parent.height + radius: 2 + color: Appearance.m3colors.m3primary + } + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: (Services.Network.activeNetwork?.strength ?? 0) + "%" + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: Appearance.m3colors.m3primary + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + border.width: 1 + border.color: Appearance.colors.colOutlineVariant + + readonly property string targetSsid: Services.Network.activeNetwork ? Services.Network.activeNetwork.ssid : "" + readonly property real pingValue: Services.Network.speedTestPing(targetSsid) + readonly property bool hasResult: pingValue >= 0 + + ColumnLayout { + anchors.centerIn: parent + spacing: 6 + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: "timer" + font.pixelSize: 24 + color: Appearance.m3colors.m3primary + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Latency" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: parent.parent.hasResult + ? parent.parent.pingValue.toFixed(0) + " ms" + : "…" + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: parent.parent.hasResult ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + height: 100 + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + border.width: 1 + border.color: Appearance.colors.colOutlineVariant + + readonly property string targetSsid: Services.Network.activeNetwork ? Services.Network.activeNetwork.ssid : "" + readonly property real downloadValue: Services.Network.speedTestDownload(targetSsid) + + ColumnLayout { + anchors.centerIn: parent + spacing: 6 + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: "arrow_downward" + font.pixelSize: 24 + color: Appearance.m3colors.m3primary + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Download" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: parent.parent.downloadValue >= 0 + ? parent.parent.downloadValue.toFixed(1) + " Mbps" + : "…" + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: parent.parent.downloadValue >= 0 ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + border.width: 1 + border.color: Appearance.colors.colOutlineVariant + + readonly property string targetSsid: Services.Network.activeNetwork ? Services.Network.activeNetwork.ssid : "" + readonly property real uploadValue: Services.Network.speedTestUpload(targetSsid) + + ColumnLayout { + anchors.centerIn: parent + spacing: 6 + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: "arrow_upward" + font.pixelSize: 24 + color: Appearance.m3colors.m3primary + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Upload" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: parent.parent.uploadValue >= 0 + ? parent.parent.uploadValue.toFixed(1) + " Mbps" + : "…" + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: parent.parent.uploadValue >= 0 ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext + } + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: Appearance.colors.colOutlineVariant } + + GridLayout { + id: infoGrid + Layout.fillWidth: true + columns: 3 + columnSpacing: 12 + rowSpacing: 12 + + Repeater { + model: root.filteredDetailItems + + delegate: Rectangle { + required property var modelData + required property int index + + Layout.fillWidth: true + Layout.minimumWidth: 0 + Layout.preferredWidth: 1 + implicitHeight: infoContent.implicitHeight + 24 + clip: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + border.width: 1 + border.color: Appearance.colors.colOutlineVariant + + ColumnLayout { + id: infoContent + anchors.centerIn: parent + width: parent.width - 16 + spacing: 4 - StyledText { - Layout.alignment: Qt.AlignHCenter - text: "Turn on WiFi to see available networks" - color: Appearance.colors.colOnLayer1 - font.pixelSize: Appearance.font.pixelSize.large - horizontalAlignment: Text.AlignHCenter + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: modelData.icon + font.pixelSize: 20 + color: Appearance.m3colors.m3primary + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: modelData.label + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Layout.minimumWidth: 0 + width: infoContent.width + text: Services.Network.detailValue(modelData.valueIdx) + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: Appearance.colors.colOnLayer1 + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + } + } + } + } } } + } - StyledTextArea { - id: networkSearch - Layout.fillWidth: true - Layout.leftMargin: 16 - Layout.rightMargin: 16 - placeholderText: "Search networks" - visible: Network.wifiEnabled || false + Item { + Layout.fillWidth: true + readonly property real targetHeight: Services.Network.activeNetwork !== null + ? customDnsSection.implicitHeight : 0 + height: targetHeight + implicitHeight: targetHeight + clip: true + + Behavior on height { + NumberAnimation { duration: 180; easing.type: Easing.InOutQuad } } + ContentSection { + id: customDnsSection + width: parent.width + visible: Services.Network.activeNetwork !== null + title: "Custom DNS" + icon: "dns" + + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + + ConfigSwitch { + text: "Custom DNS" + checked: root.customDnsEnabled + enabled: !Services.Network.dnsApplying + onClicked: { + root.customDnsEnabled = !root.customDnsEnabled; + Config.options.networking.dnsSwitch = root.customDnsEnabled; + Services.Network.applyDnsSettings(root.customDnsEnabled, root.customDnsProviderId); + } + StyledToolTip { + text: Services.Network.dnsApplying + ? "Applying DNS settings…" + : (root.customDnsEnabled + ? "Click to use this network's default DNS" + : "Click to use a custom DNS provider") + } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 10 + visible: root.customDnsEnabled + + Repeater { + model: [ + { group: "General", ids: ["cloudflare", "google", "quad9"] }, + { group: "Security", ids: ["cloudflare-malware", "opendns"] }, + { group: "Family", ids: ["cloudflare-family", "opendns-family"] } + ] + + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + spacing: 4 + + StyledText { + text: modelData.group + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Repeater { + model: modelData.ids + + delegate: Rectangle { + required property string modelData + required property int index + + readonly property var provider: Services.Network.dnsProviderMap[modelData] ?? null + readonly property bool isSelected: root.customDnsProviderId === modelData + readonly property string primaryIp: provider?.servers.split(",")[0] ?? "" + + Layout.fillWidth: true + height: 64 + radius: Appearance.rounding.small + + color: isSelected + ? Qt.rgba(Appearance.m3colors.m3primaryContainer.r, + Appearance.m3colors.m3primaryContainer.g, + Appearance.m3colors.m3primaryContainer.b, 0.55) + : Appearance.colors.colLayer2 + border.width: isSelected ? 0 : 1 + border.color: Appearance.colors.colOutlineVariant + + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.width { NumberAnimation { duration: 150 } } + + ColumnLayout { + anchors { fill: parent; margins: 8; topMargin: 12 } + spacing: 2 + + RowLayout { + spacing: 4 + + MaterialSymbol { + visible: isSelected + text: "check" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3primary + } - Repeater { - id: networkRepeater - visible: Network.wifiEnabled || false - model: ScriptModel { - id: networkModel - // values: [...Network.networks].sort((a, b) => { - // if (a.active !== b.active) - // return b.active - a.active; - // return b.strength - a.strength; - // }).slice(0, 8) - - values: { - // Force model re-evaluation when network state changes - let trigger = root.refreshTrigger; - - let networks = [...Network.networks].sort((a, b) => { - if (a.active !== b.active) - return b.active - a.active; - return b.strength - a.strength; - }); - if (networkSearch.text.trim() !== "") { - networks = networks.filter(n => n.ssid.toLowerCase().includes(networkSearch.text.toLowerCase())); + StyledText { + text: provider?.name ?? "" + font.pixelSize: Appearance.font.pixelSize.normal + font.weight: isSelected ? 600 : 400 + color: isSelected + ? Appearance.m3colors.m3primary + : Appearance.colors.colOnLayer1 + Behavior on color { ColorAnimation { duration: 150 } } + } + } + + StyledText { + text: primaryIp + font.pixelSize: Appearance.font.pixelSize.small + color: isSelected + ? Appearance.m3colors.m3primary + : Appearance.colors.colSubtext + opacity: isSelected ? 0.75 : 1.0 + Behavior on color { ColorAnimation { duration: 150 } } + } + } + + MouseArea { + anchors.fill: parent + enabled: !Services.Network.dnsApplying + cursorShape: Qt.PointingHandCursor + onClicked: { + root.customDnsProviderId = modelData; + Config.options.networking.dnsProvider = modelData; + Services.Network.applyDnsSettings(root.customDnsEnabled, modelData); + } + } + } + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + visible: Services.Network.dnsApplyError !== "" + + MaterialSymbol { + text: "error_outline" + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3error + } + + StyledText { + Layout.fillWidth: true + wrapMode: Text.WordWrap + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3error + text: Services.Network.dnsApplyError } - return networks; } } + } + } + + ContentSection { + title: "Networks" + icon: "wifi" + + ColumnLayout { + Layout.fillWidth: true + spacing: 18 RowLayout { - id: networkItem + Layout.fillWidth: true + spacing: 14 - required property var modelData - readonly property bool isConnecting: Network.connectingToSsid === modelData.ssid - readonly property bool loading: networkItem.isConnecting + RowLayout { + Layout.fillWidth: true + visible: !root.networkSearchVisible + spacing: 10 - property bool expanded: false - + StyledText { + text: { + const c = (Network.networks || []).length; + return qsTr("%1 network%2 available").arg(c).arg(c === 1 ? "" : "s"); + } + color: Appearance.colors.colOnLayer0 + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 500 + } + Rectangle { + visible: Services.Network.activeNetwork !== null + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + implicitWidth: connectedPillRow.implicitWidth + 20 + implicitHeight: connectedPillRow.implicitHeight + 10 - Layout.fillWidth: true - spacing: 10 + RowLayout { + id: connectedPillRow + anchors.centerIn: parent + spacing: 6 + + Rectangle { + Layout.alignment: Qt.AlignVCenter + width: 6; height: 6 + radius: Appearance.rounding.full + color: Appearance.m3colors.m3primary + + SequentialAnimation on opacity { + running: Services.Network.activeNetwork !== null + loops: Animation.Infinite + NumberAnimation { from: 1; to: 0.35; duration: 900; easing.type: Easing.InOutQuad } + NumberAnimation { from: 0.35; to: 1; duration: 900; easing.type: Easing.InOutQuad } + } + } - + StyledText { + text: qsTr("Connected") + font.pixelSize: Appearance.font.pixelSize.small + font.weight: 500 + color: Appearance.m3colors.m3primary + } + } + } + + Item { Layout.fillWidth: true } + } Rectangle { - id: netRect Layout.fillWidth: true - implicitHeight: netCard.height + dropDownBox.height + visible: root.networkSearchVisible + implicitHeight: searchInputField.implicitHeight radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + RowLayout { + anchors.fill: parent + spacing: 0 + + MaterialSymbol { + Layout.leftMargin: 8 + text: "search" + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colSubtext + } - color: Appearance.colors.colLayer2 - - ColumnLayout { - width: parent.width - id: netCard + StyledTextInput { + id: searchInputField + Layout.fillWidth: true + padding: 8 + color: Appearance.colors.colOnLayer1 + verticalAlignment: TextInput.AlignVCenter + focus: root.networkSearchVisible + onTextChanged: root.searchText = text + + Text { + text: "Search networks…" + color: Appearance.m3colors.m3outline + font.pixelSize: Appearance.font.pixelSize.small + visible: !searchInputField.text && !searchInputField.activeFocus + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 4 + } + } - RowLayout { - spacing: 10 - Layout.margins: 10 - - RowLayout { + RippleButton { + id: clearSearchBtn + visible: searchInputField.text.length > 0 + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: 5 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + colBackground: "transparent" + colBackgroundHover: "transparent" + colBackgroundToggled: "transparent" + colBackgroundToggledHover: "transparent" + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: searchInputField.text = "" + } - MaterialSymbol { - text: Network.getNetworkIcon(networkItem.modelData.strength) - font.pixelSize: Appearance.font.pixelSize.title - color: Appearance.colors.colOnSecondaryContainer - } + contentItem: MaterialSymbol { + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer2Disabled + text: "close" + } + } + } + } - MaterialSymbol { - visible: networkItem.modelData?.isSecure || false - text: "lock" - font.pixelSize: Appearance.font.pixelSize.larger - color: Appearance.colors.colOnSecondaryContainer - } + RippleButton { + id: discoverBtn + visible: Services.Network.wifiEnabled + + contentItem: Rectangle { + radius: Appearance.rounding.full + color: Services.Network.wifiScanning ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 + implicitWidth: height + + MaterialSymbol { + anchors.centerIn: parent + text: "refresh" + color: Services.Network.wifiScanning + ? Appearance.m3colors.m3onSecondary + : Appearance.m3colors.m3onSecondaryContainer + fill: Services.Network.wifiScanning ? 1 : 0 + } + } + + MouseArea { + id: discoverArea + anchors.fill: parent + hoverEnabled: true + onClicked: Network.rescanWifi() + + StyledToolTip { + extraVisibleCondition: discoverArea.containsMouse + text: "Discover new networks" + } + } + } + + RippleButton { + id: searchToggleBtn + visible: Services.Network.wifiEnabled + + contentItem: Rectangle { + radius: Appearance.rounding.full + color: root.networkSearchVisible ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 + implicitWidth: height + + MaterialSymbol { + anchors.centerIn: parent + text: "search" + color: root.networkSearchVisible + ? Appearance.m3colors.m3onSecondary + : Appearance.m3colors.m3onSecondaryContainer + fill: root.networkSearchVisible ? 1 : 0 + } + } + MouseArea { + id: searchToggleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.networkSearchVisible = !root.networkSearchVisible; + if (!root.networkSearchVisible) { + searchInputField.text = ""; + root.searchText = ""; + } else { + Qt.callLater(searchInputField.forceActiveFocus); } + } - ColumnLayout { - id: cardTexts + StyledToolTip { + extraVisibleCondition: searchToggleArea.containsMouse + text: root.networkSearchVisible ? "Hide search" : "Search networks" + } + } + } + } - StyledText { - Layout.fillWidth: true - text: networkItem.modelData.ssid - font.pixelSize: Appearance.font.pixelSize.large - font.weight: networkItem.modelData.active ? 500 : 400 - color: networkItem.modelData.active ? Appearance.m3colors.m3primary : Appearance.colors.colOnLayer1 - } + ColumnLayout { + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.topMargin: 12 + Layout.bottomMargin: 12 + visible: !Services.Network.wifiEnabled + spacing: 14 - StyledText { - Layout.fillWidth: true - text: "Open Network" - font.pixelSize: Appearance.font.pixelSize.small - color: Appearance.colors.colSubtext - visible: !(networkItem.modelData?.isSecure || false) - } + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: "signal_wifi_off" + font.pixelSize: 48 + color: Appearance.colors.colOnLayer1 + } - // Connection error display for this network - StyledText { - Layout.fillWidth: true - text: "Failed to connect: " + root.lastConnectionError - font.pixelSize: Appearance.font.pixelSize.small - color: Appearance.m3colors.m3error - wrapMode: Text.WordWrap - visible: root.showConnectionError && root.errorSsid === networkItem.modelData.ssid - } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Turn on WiFi to see available networks" + color: Appearance.colors.colOnLayer1 + font.pixelSize: Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: Services.Network.wifiEnabled + + Repeater { + id: networkRepeater + + model: ScriptModel { + id: networkModel + values: { + let _t = Services.Network.refreshTrigger; // forces re-evaluation on bump + const search = root.searchText.toLowerCase().trim(); + let nets = [...(Network.networks || [])]; + if (search) nets = nets.filter(n => n.ssid.toLowerCase().includes(search)); + nets.sort((a, b) => a.active !== b.active ? (b.active - a.active) : (b.strength - a.strength)); + return nets; + } + } + + RowLayout { + id: networkItem + + required property var modelData + property bool expanded: false + + readonly property bool isActive: modelData?.active || false + readonly property bool isSecure: modelData?.isSecure || false + readonly property bool isKnown: modelData?.isKnown || false + + Layout.fillWidth: true + spacing: 10 + + onExpandedChanged: { + if (!expanded) { + passwdInput.text = ""; + showButton.toggled = false; } + } + + Rectangle { + id: netRect + Layout.fillWidth: true + implicitHeight: netCard.height + dropDownBox.height + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + border.width: networkItem.isActive ? 2 : 0 + border.color: Appearance.m3colors.m3primary + + Behavior on border.width { NumberAnimation { duration: 150 } } - RippleButton { - id: expandBtn - visible: networkItem.modelData?.isSecure || false + ColumnLayout { + id: netCard + width: parent.width - contentItem: Rectangle { - id: expandBtnBody - radius: Appearance.rounding.full - color: Appearance.colors.colLayer2 - implicitWidth: height // makes it a perfect circle + RowLayout { + spacing: 16 + Layout.margins: 18 - MaterialSymbol { - id: expandIcon + RowLayout { + spacing: 6 - anchors.centerIn: parent - text: networkItem.expanded ? "keyboard_arrow_up" : "keyboard_arrow_down" - color: Appearance.m3colors.m3onSecondaryContainer - fill: 0 + MaterialSymbol { + text: Network.getNetworkIcon(networkItem.modelData.strength) + font.pixelSize: Appearance.font.pixelSize.title + color: Appearance.colors.colOnSecondaryContainer + } + + MaterialSymbol { + visible: networkItem.isSecure + text: "lock" + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSecondaryContainer + } } - } - MouseArea { - id: expandArea - anchors.fill: parent - hoverEnabled: true + ColumnLayout { + Layout.fillWidth: true + spacing: 4 - onClicked: networkItem.expanded = !networkItem.expanded + StyledText { + Layout.fillWidth: true + text: networkItem.modelData.ssid + font.pixelSize: Appearance.font.pixelSize.large + font.weight: networkItem.isActive ? 500 : 400 + color: networkItem.isActive + ? Appearance.m3colors.m3primary + : Appearance.colors.colOnLayer1 + } + + StyledText { + Layout.fillWidth: true + text: "Open Network" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + visible: !networkItem.isSecure + } - StyledToolTip { - extraVisibleCondition: expandArea.containsMouse - text: networkItem.expanded ? "Collapse" : "Expand" + StyledText { + Layout.fillWidth: true + text: "Failed to connect: " + Services.Network.lastConnectionError + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3error + wrapMode: Text.WordWrap + visible: Services.Network.showConnectionError && Services.Network.errorSsid === networkItem.modelData.ssid + } } - } - } - Item { - Layout.fillWidth: false - Layout.preferredWidth: toggleSwitch.width - Layout.preferredHeight: toggleSwitch.height - - StyledSwitch { - id: toggleSwitch - anchors.centerIn: parent - scale: 0.80 - checked: networkItem.modelData?.active || false - enabled: false // Make it visual-only - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: function(mouse) { - mouse.accepted = true; // Prevent event propagation - const isActive = networkItem.modelData?.active || false; - const isSecure = networkItem.modelData?.isSecure || false; - const isKnown = networkItem.modelData?.isKnown || false; - - if (isActive) { - // Currently connected - disconnect - Network.disconnectFromNetwork(); - } else { - // Not connected - try to connect - if (!isSecure) { - // Open network - connect directly - Network.connectToNetwork(networkItem.modelData.ssid, ""); - } else if (isKnown) { - // Known secure network - if previous attempt failed, prompt for password - // otherwise try stored credentials - const hasFailed = Network.hasConnectionFailed(networkItem.modelData.ssid); - if (hasFailed) { - // Show password input so user can re-enter credentials - // Expand to show password input for re-authentication - Qt.callLater(function() { - networkItem.expanded = true; - }); - } else { - // Backend will handle security mismatches automatically - Network.connectToNetwork(networkItem.modelData.ssid, ""); - } - } else { - // Unknown secure network - expand for password input - networkItem.expanded = true; + RippleButton { + id: expandBtn + visible: networkItem.isSecure || networkItem.isActive + + contentItem: Rectangle { + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + implicitWidth: height + + MaterialSymbol { + anchors.centerIn: parent + text: networkItem.expanded ? "keyboard_arrow_up" : "keyboard_arrow_down" + color: Appearance.m3colors.m3onSecondaryContainer + fill: 0 + } + } + + MouseArea { + id: expandArea + anchors.fill: parent + hoverEnabled: true + onClicked: networkItem.expanded = !networkItem.expanded + + StyledToolTip { + extraVisibleCondition: expandArea.containsMouse + text: networkItem.expanded ? "Collapse" : "Expand" } } } - } - - StyledToolTip { - text: { - if (networkItem.modelData?.active) { - return "Disconnect from network"; - } else if (networkItem.modelData?.isKnown) { - return "Connect to known network"; - } else if (networkItem.modelData?.isSecure) { - return "Click to enter password"; - } else { - return "Click to connect to open network"; + + Item { + Layout.fillWidth: false + Layout.preferredWidth: toggleSwitch.width + Layout.preferredHeight: toggleSwitch.height + + StyledSwitch { + id: toggleSwitch + anchors.centerIn: parent + scale: 0.80 + checked: networkItem.isActive + enabled: false + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: (mouse) => { + mouse.accepted = true; + const isActive = networkItem.isActive; + const isSecure = networkItem.isSecure; + const isKnown = networkItem.isKnown; + + if (isActive) { + Network.disconnectFromNetwork(); + } else if (!isSecure) { + Network.connectToNetwork(networkItem.modelData.ssid, ""); + } else if (isKnown) { + if (Network.hasConnectionFailed(networkItem.modelData.ssid)) { + Qt.callLater(() => { networkItem.expanded = true; }); + } else { + Network.connectToNetwork(networkItem.modelData.ssid, ""); + } + } else { + networkItem.expanded = true; + } + } + + StyledToolTip { + text: networkItem.isActive + ? "Disconnect from network" + : networkItem.isKnown + ? "Connect to known network" + : networkItem.isSecure + ? "Click to enter password" + : "Click to connect to open network" + visible: parent.containsMouse || false + } } } - visible: parent.containsMouse || false } - } // Close Item container - } + } - Rectangle { - id: dropDownBox - Layout.fillWidth: true - height: networkItem.expanded ? dropDownContent.implicitHeight + 16 : 0 - color: "transparent" - opacity: networkItem.expanded ? 1 : 0 - visible: height > 0 + Rectangle { + id: dropDownBox + anchors.top: netCard.bottom + width: parent.width + height: networkItem.expanded ? dropDownContent.implicitHeight + 36 : 0 + color: "transparent" + opacity: networkItem.expanded ? 1 : 0 + visible: height > 0 - Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.InOutQuad } } - Behavior on opacity { NumberAnimation { duration: 150 } } + Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.InOutQuad } } + Behavior on opacity { NumberAnimation { duration: 150 } } - ColumnLayout { - id: dropDownContent - anchors.fill: parent - anchors.margins: 8 - spacing: 4 - StyledText { text: "BSSID: " + networkItem.modelData.bssid } - StyledText { text: "Frequence: " + networkItem.modelData.frequency } - StyledText { text: "Security: " + networkItem.modelData.security } + ColumnLayout { + id: dropDownContent + anchors.fill: parent + anchors.margins: 18 + spacing: 0 + + Flow { + Layout.fillWidth: true + spacing: 20 + + RowLayout { + spacing: 10 + visible: root.showSensitiveInfo + MaterialSymbol { text: "fingerprint"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } + ColumnLayout { + spacing: 2 + StyledText { text: "BSSID"; font.pixelSize: Appearance.font.pixelSize.small; color: Appearance.colors.colSubtext } + StyledText { text: networkItem.modelData.bssid; font.pixelSize: Appearance.font.pixelSize.small } + } + } + RowLayout { + spacing: 10 + MaterialSymbol { text: "settings_input_antenna"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } + ColumnLayout { + spacing: 2 + StyledText { text: "Frequency"; font.pixelSize: Appearance.font.pixelSize.small; color: Appearance.colors.colSubtext } + StyledText { text: Services.Network.formatFrequency(networkItem.modelData.frequency); font.pixelSize: Appearance.font.pixelSize.small } + } + } + RowLayout { + spacing: 10 + MaterialSymbol { text: "encrypted"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } + ColumnLayout { + spacing: 2 + StyledText { text: "Security"; font.pixelSize: Appearance.font.pixelSize.small; color: Appearance.colors.colSubtext } + StyledText { text: networkItem.modelData.security; font.pixelSize: Appearance.font.pixelSize.small } + } + } - StyledText { - text: "Password:" - visible: (networkItem.modelData?.isSecure || false) && (!(networkItem.modelData?.isKnown || false) || Network.hasConnectionFailed(networkItem.modelData.ssid)) - } - Rectangle { - id: inputWrapper - visible: (networkItem.modelData?.isSecure || false) && (!(networkItem.modelData?.isKnown || false) || Network.hasConnectionFailed(networkItem.modelData.ssid)) - Layout.fillWidth: true - radius: Appearance.rounding.small - color: Appearance.colors.colLayer1 - height: passwdInput.height - clip: true - border.color: Appearance.colors.colOutlineVariant - border.width: 1 - - RowLayout { // Input field and show button - id: inputFieldRowLayout - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: 5 - spacing: 0 - - StyledTextInput { - id: passwdInput - Layout.fillWidth: true - padding: 10 - //placeholderText: "Password" - color: Appearance.colors.colOnLayer1 - echoMode: showButton.toggled ? TextInput.Normal : TextInput.Password - passwordCharacter: "●" - passwordMaskDelay: 0 - verticalAlignment: TextInput.AlignVCenter - - Text { - text: "Enter password..." - color: Appearance.m3colors.m3outline - font.pixelSize: Appearance?.font.pixelSize.small - visible: !passwdInput.text && !passwdInput.activeFocus - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 10 + RowLayout { + id: latencyRow + readonly property real pingValue: Services.Network.speedTestPing(networkItem.modelData.ssid) + readonly property bool isLive: Services.Network.speedTestIsLive(networkItem.modelData.ssid) + spacing: 10 + visible: pingValue >= 0 || isLive + MaterialSymbol { text: "timer"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } + ColumnLayout { + spacing: 2 + StyledText { text: "Latency"; font.pixelSize: Appearance.font.pixelSize.small; color: Appearance.colors.colSubtext } + StyledText { + text: latencyRow.pingValue >= 0 + ? latencyRow.pingValue.toFixed(0) + " ms" + : "…" + font.pixelSize: Appearance.font.pixelSize.small + } } + } - Keys.onPressed: function (event) { - if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - Network.connectToNetwork(networkItem.modelData.ssid, passwdInput.text); - networkItem.expanded = false + RowLayout { + id: downloadRow + readonly property real downloadValue: Services.Network.speedTestDownload(networkItem.modelData.ssid) + readonly property bool isLive: Services.Network.speedTestIsLive(networkItem.modelData.ssid) + spacing: 10 + visible: downloadValue >= 0 || isLive + MaterialSymbol { text: "arrow_downward"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } + ColumnLayout { + spacing: 2 + StyledText { text: "Download"; font.pixelSize: Appearance.font.pixelSize.small; color: Appearance.colors.colSubtext } + StyledText { + text: downloadRow.downloadValue >= 0 + ? downloadRow.downloadValue.toFixed(1) + " Mbps" + : "…" + font.pixelSize: Appearance.font.pixelSize.small } } } - RippleButton { // Show button - id: showButton - Layout.alignment: Qt.AlignTop - Layout.leftMargin: 5 - implicitHeight: 40 - buttonRadius: Appearance.rounding.small - toggled: false - - colBackground: "transparent" - colBackgroundHover: "transparent" - colBackgroundToggled: "transparent" - colBackgroundToggledHover: "transparent" - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - showButton.toggled = !showButton.toggled + RowLayout { + id: uploadRow + readonly property real uploadValue: Services.Network.speedTestUpload(networkItem.modelData.ssid) + readonly property bool isLive: Services.Network.speedTestIsLive(networkItem.modelData.ssid) + spacing: 10 + visible: uploadValue >= 0 || isLive + MaterialSymbol { text: "arrow_upward"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } + ColumnLayout { + spacing: 2 + StyledText { text: "Upload"; font.pixelSize: Appearance.font.pixelSize.small; color: Appearance.colors.colSubtext } + StyledText { + text: uploadRow.uploadValue >= 0 + ? uploadRow.uploadValue.toFixed(1) + " Mbps" + : "…" + font.pixelSize: Appearance.font.pixelSize.small } } + } + } - contentItem: MaterialSymbol { - anchors.centerIn: parent - horizontalAlignment: Text.AlignHCenter - iconSize: Appearance.font.pixelSize.larger - color: showButton.toggled ? Appearance.colors.colOnLayer2 : Appearance.colors.colOnLayer2Disabled - text: showButton.toggled ? "visibility" : "visibility_off" + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 16 + spacing: 10 + visible: networkItem.isSecure && + (!networkItem.isKnown || + Network.hasConnectionFailed(networkItem.modelData.ssid)) + + Rectangle { Layout.fillWidth: true; height: 1; color: Appearance.colors.colOutlineVariant } + + StyledText { text: "Password"; font.pixelSize: Appearance.font.pixelSize.small; color: Appearance.colors.colSubtext } + + Rectangle { + id: inputWrapper + Layout.fillWidth: true + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + implicitHeight: passwdInput.implicitHeight + clip: true + border.color: Appearance.colors.colOutlineVariant + border.width: 1 + + RowLayout { + anchors { left: parent.left; right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 0 + + StyledTextInput { + id: passwdInput + Layout.fillWidth: true + padding: 12 + color: Appearance.colors.colOnLayer1 + echoMode: showButton.toggled ? TextInput.Normal : TextInput.Password + passwordCharacter: "●" + passwordMaskDelay: 0 + verticalAlignment: TextInput.AlignVCenter + + Text { + text: "Enter password..." + color: Appearance.m3colors.m3outline + font.pixelSize: Appearance.font.pixelSize.small + visible: !passwdInput.text && !passwdInput.activeFocus + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 10 + } + + Keys.onPressed: (event) => { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + Network.connectToNetwork(networkItem.modelData.ssid, passwdInput.text); + networkItem.expanded = false; + } + } + } + + RippleButton { + id: showButton + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 5 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + toggled: false + colBackground: "transparent" + colBackgroundHover: "transparent" + colBackgroundToggled: "transparent" + colBackgroundToggledHover: "transparent" + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: showButton.toggled = !showButton.toggled + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: showButton.toggled + ? Appearance.colors.colOnLayer2 + : Appearance.colors.colOnLayer2Disabled + text: showButton.toggled ? "visibility" : "visibility_off" + } + } + + RippleButton { + id: sendButton + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: 5 + implicitHeight: 40 + buttonRadius: Appearance.rounding.small + enabled: passwdInput.text.length !== 0 + colBackground: "transparent" + colBackgroundHover: "transparent" + colBackgroundToggled: "transparent" + colBackgroundToggledHover: "transparent" + + MouseArea { + anchors.fill: parent + cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + Network.connectToNetwork(networkItem.modelData.ssid, passwdInput.text); + networkItem.expanded = false; + } + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + iconSize: Appearance.font.pixelSize.larger + color: sendButton.enabled + ? Appearance.colors.colOnLayer2 + : Appearance.colors.colOnLayer2Disabled + text: "lock_open_right" + } + } } } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 16 + spacing: 14 + visible: networkItem.isActive || networkItem.isKnown - RippleButton { // Connect button - id: sendButton - Layout.alignment: Qt.AlignTop - Layout.rightMargin: 5 - implicitHeight: 40 - buttonRadius: Appearance.rounding.small - enabled: passwdInput.text.length != 0 - - colBackground: "transparent" - colBackgroundHover: "transparent" - colBackgroundToggled: "transparent" - colBackgroundToggledHover: "transparent" - - MouseArea { - anchors.fill: parent - cursorShape: sendButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + Rectangle { Layout.fillWidth: true; height: 1; color: Appearance.colors.colOutlineVariant } + + RowLayout { + Layout.fillWidth: true + spacing: 14 + + RowLayout { + spacing: 6 + visible: networkItem.isActive + + RippleButtonWithIcon { + materialIcon: "speed" + mainText: Services.Network.speedTestRunning + ? "Testing…" + : Services.Network.speedTestIsDone(networkItem.modelData.ssid) + ? "Test Again" + : "Speed Test" + enabled: !Services.Network.speedTestRunning + onClicked: Services.Network.startSpeedTest() + } + + Rectangle { + Layout.preferredWidth: 6 + Layout.preferredHeight: 6 + Layout.alignment: Qt.AlignVCenter + radius: 3 + color: Appearance.m3colors.m3primary + visible: Services.Network.speedTestRunning + + SequentialAnimation on opacity { + running: Services.Network.speedTestRunning && networkItem.expanded + loops: Animation.Infinite + NumberAnimation { from: 1; to: 0.25; duration: 550; easing.type: Easing.InOutQuad } + NumberAnimation { from: 0.25; to: 1; duration: 550; easing.type: Easing.InOutQuad } + } + } + } + + RippleButtonWithIcon { + materialIcon: "qr_code_2" + mainText: "Share QR" + visible: networkItem.isActive + enabled: true onClicked: { - Network.connectToNetwork(networkItem.modelData.ssid, passwdInput.text); - networkItem.expanded = false + if (Services.Network.qrGenerating) + return; + + if (Services.Network.qrActiveSsid === networkItem.modelData.ssid && + (Services.Network.qrImagePath !== "" || Services.Network.qrError !== "")) { + Services.Network.qrActiveSsid = ""; + Services.Network.qrImagePath = ""; + Services.Network.qrError = ""; + qrPanel.opacity = 0; + qrFadeInAnim.stop(); + } else { + Services.Network.generateQrCode(networkItem.modelData.ssid, + networkItem.modelData.security || ""); + qrPanel.opacity = 0; + qrFadeInAnim.restart(); + } } } - contentItem: MaterialSymbol { - anchors.centerIn: parent - horizontalAlignment: Text.AlignHCenter - iconSize: Appearance.font.pixelSize.larger - color: sendButton.enabled ? Appearance.colors.colOnLayer2 : Appearance.colors.colOnLayer2Disabled - text: "lock_open_right" + RippleButtonWithIcon { + materialIcon: "delete" + mainText: "Forget Network" + visible: networkItem.isKnown + onClicked: { + Network.forgetNetwork(networkItem.modelData.ssid); + networkItem.expanded = false; + } } + + Item { Layout.fillWidth: true } } + Item { + Layout.fillWidth: true + readonly property bool qrActive: Services.Network.qrActiveSsid === networkItem.modelData.ssid && + networkItem.isActive && + (Services.Network.qrGenerating || Services.Network.qrImagePath !== "" || Services.Network.qrError !== "") + readonly property real targetH: qrActive ? qrPanel.implicitHeight : 0 + height: targetH + implicitHeight: targetH + clip: true + + Behavior on height { NumberAnimation { duration: 260; easing.type: Easing.InOutQuad } } + + SequentialAnimation { + id: qrFadeInAnim + PauseAnimation { duration: 150 } + NumberAnimation { target: qrPanel; property: "opacity"; to: 1; duration: 220; easing.type: Easing.InOutQuad } + } + ColumnLayout { + id: qrPanel + width: parent.width + spacing: 10 + opacity: 0 + + Rectangle { Layout.fillWidth: true; height: 1; color: Appearance.colors.colOutlineVariant } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 76 + radius: Appearance.rounding.small + clip: true + visible: Services.Network.qrGenerating && + Services.Network.qrActiveSsid === networkItem.modelData.ssid + color: Qt.rgba(Appearance.m3colors.m3primaryContainer.r, + Appearance.m3colors.m3primaryContainer.g, + Appearance.m3colors.m3primaryContainer.b, 0.22) + + RowLayout { + anchors { + left: parent.left; right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: 16; rightMargin: 16 + } + spacing: 14 + + MaterialSymbol { + text: "qr_code_2" + font.pixelSize: 30 + color: Appearance.m3colors.m3primary + + SequentialAnimation on opacity { + running: Services.Network.qrGenerating && Services.Network.qrActiveSsid === networkItem.modelData.ssid + loops: Animation.Infinite + NumberAnimation { from: 1.0; to: 0.2; duration: 650; easing.type: Easing.InOutQuad } + NumberAnimation { from: 0.2; to: 1.0; duration: 650; easing.type: Easing.InOutQuad } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + StyledText { + text: "Generating QR code" + font.pixelSize: Appearance.font.pixelSize.normal + font.weight: 500 + color: Appearance.m3colors.m3primary + } + StyledText { + text: "Retrieving network credentials…" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + } + } + + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + height: 3 + color: Qt.rgba(Appearance.m3colors.m3primary.r, + Appearance.m3colors.m3primary.g, + Appearance.m3colors.m3primary.b, 0.25) + + Rectangle { + id: qrScanBar + height: parent.height + width: 80 + radius: 2 + color: Appearance.m3colors.m3primary + + SequentialAnimation on x { + running: Services.Network.qrGenerating && Services.Network.qrActiveSsid === networkItem.modelData.ssid + loops: Animation.Infinite + NumberAnimation { from: -qrScanBar.width; to: qrScanBar.parent.width; duration: 1000; easing.type: Easing.InOutCubic } + } + } + } + } - } - } + Rectangle { + Layout.fillWidth: true + implicitHeight: qrErrorRow.implicitHeight + 24 + radius: Appearance.rounding.small + visible: Services.Network.qrError !== "" && + Services.Network.qrActiveSsid === networkItem.modelData.ssid + color: Qt.rgba(Appearance.m3colors.m3errorContainer.r, + Appearance.m3colors.m3errorContainer.g, + Appearance.m3colors.m3errorContainer.b, 0.35) + + RowLayout { + id: qrErrorRow + anchors { + left: parent.left; right: parent.right; top: parent.top + margins: 14 + } + spacing: 10 + + MaterialSymbol { + Layout.alignment: Qt.AlignTop + text: "error_outline" + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.m3colors.m3error + } + StyledText { + Layout.fillWidth: true + text: Services.Network.qrError + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3error + wrapMode: Text.WordWrap + } + } + } - // Forget button for known networks, separated at bottom - RippleButtonWithIcon { - Layout.topMargin: 8 - materialIcon: "delete" - mainText: "Forget Network" - visible: networkItem.modelData?.isKnown || false - onClicked: { - Network.forgetNetwork(networkItem.modelData.ssid); - networkItem.expanded = false; + Rectangle { + Layout.fillWidth: true + implicitHeight: qrReadyRow.implicitHeight + 32 + radius: Appearance.rounding.small + visible: Services.Network.qrImagePath !== "" && + Services.Network.qrActiveSsid === networkItem.modelData.ssid + color: Qt.rgba(Appearance.m3colors.m3primaryContainer.r, + Appearance.m3colors.m3primaryContainer.g, + Appearance.m3colors.m3primaryContainer.b, 0.22) + + RowLayout { + id: qrReadyRow + anchors { + left: parent.left; right: parent.right; top: parent.top + leftMargin: 16; rightMargin: 16; topMargin: 16 + } + spacing: 20 + + Rectangle { + width: 148; height: 148 + color: "white" + radius: Appearance.rounding.small + + Image { + anchors { fill: parent; margins: 8 } + source: Services.Network.qrImagePath + smooth: false + fillMode: Image.PreserveAspectFit + cache: false + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 8 + + StyledText { + Layout.fillWidth: true + text: Services.Network.qrActiveSsid + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: Appearance.m3colors.m3primary + elide: Text.ElideRight + } + + Rectangle { + radius: Appearance.rounding.full + color: Qt.rgba(Appearance.m3colors.m3primary.r, + Appearance.m3colors.m3primary.g, + Appearance.m3colors.m3primary.b, 0.12) + implicitWidth: qrSecBadge.implicitWidth + 16 + implicitHeight: qrSecBadge.implicitHeight + 8 + + RowLayout { + id: qrSecBadge + anchors.centerIn: parent + spacing: 4 + + MaterialSymbol { + text: networkItem.isSecure ? "lock" : "lock_open" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3primary + } + StyledText { + text: networkItem.modelData.security || "Open" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.m3colors.m3primary + } + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Appearance.colors.colOutlineVariant + opacity: 0.5 + } + + StyledText { + Layout.fillWidth: true + text: "Point your phone's camera at this code to connect instantly — no typing required." + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + wrapMode: Text.WordWrap + } + } + } + } + } + } } } } } } } - } } } diff --git a/src/share/sleex/services/Network.qml b/src/share/sleex/services/Network.qml new file mode 100644 index 00000000..bb3c10a3 --- /dev/null +++ b/src/share/sleex/services/Network.qml @@ -0,0 +1,552 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.services +import qs.modules.common +import qs.modules.common.functions +import Sleex.Services + +// Bare "Network" below refers to the C++ singleton (Sleex.Services), not this file +Singleton { + id: root + + property string lastConnectionError: "" + property string errorSsid: "" + property bool showConnectionError: false + + property int refreshTrigger: 0 + function bumpRefresh() { root.refreshTrigger++; } + + readonly property var activeNetwork: Network.active || null + + property string activeConnectionType: "" + + // ~/.cache/sleex/network, resolved synchronously via Directories + readonly property string cacheDir: FileUtils.trimFileProtocol(`${Directories.cache}/sleex/network`) + + // nmcli TYPE is "802-3-ethernet" for wired, "802-11-wireless" for WiFi + readonly property bool hasWiredConnection: root.activeConnectionType.includes("ethernet") + readonly property bool hasActiveConnection: root.activeNetwork !== null || root.hasWiredConnection + + readonly property bool wifiEnabled: Network.wifiEnabled || false + readonly property bool wifiScanning: Network.scanning || false + + property bool speedTestRunning: false + property string speedTestStage: "idle" + property real speedTestPingMs: -1 + property real speedTestDownloadMbps: -1 + property real speedTestUploadMbps: -1 + property string speedTestError: "" + property string speedTestSsid: "" + + property var speedTestResults: ({}) + + function cacheSpeedTestResult(ssid) { + if (!ssid) return; + const next = Object.assign({}, root.speedTestResults); + next[ssid] = { + pingMs: root.speedTestPingMs, + downloadMbps: root.speedTestDownloadMbps, + uploadMbps: root.speedTestUploadMbps + }; + root.speedTestResults = next; + try { + speedTestCacheFile.setText(JSON.stringify(next, null, 2)); + } catch (e) { + } + } + + function speedTestPing(ssid) { + if (root.speedTestSsid === ssid) return root.speedTestPingMs; + return root.speedTestResults[ssid]?.pingMs ?? -1; + } + function speedTestDownload(ssid) { + if (root.speedTestSsid === ssid) return root.speedTestDownloadMbps; + return root.speedTestResults[ssid]?.downloadMbps ?? -1; + } + function speedTestUpload(ssid) { + if (root.speedTestSsid === ssid) return root.speedTestUploadMbps; + return root.speedTestResults[ssid]?.uploadMbps ?? -1; + } + function speedTestIsLive(ssid) { + return root.speedTestRunning && root.speedTestSsid === ssid; + } + function speedTestIsDone(ssid) { + if (root.speedTestSsid === ssid) return root.speedTestStage === "done" || root.speedTestStage === "error"; + return !!root.speedTestResults[ssid]; + } + + function startSpeedTest() { + if (root.speedTestRunning) return; + root.speedTestSsid = root.activeNetwork ? root.activeNetwork.ssid : ""; + root.speedTestRunning = true; + root.speedTestStage = "ping"; + root.speedTestPingMs = -1; + root.speedTestDownloadMbps = -1; + root.speedTestUploadMbps = -1; + root.speedTestError = ""; + speedTestPingProcess.running = true; + } + + property bool qrGenerating: false + property string qrImagePath: "" + property string qrError: "" + property string qrActiveSsid: "" + property string _qrPassword: "" + + function generateQrCode(ssid, securityStr) { + root.qrActiveSsid = ssid; + root.qrGenerating = true; + root.qrImagePath = ""; + root.qrError = ""; + root._qrPassword = ""; + const sec = (securityStr || "").toLowerCase(); + if (!sec || sec === "none" || sec === "--") { + root._encodeQr(ssid, "", "nopass"); + return; + } + qrPasswordProcess.running = true; + } + + function _encodeQr(ssid, password, authType) { + function esc(s) { + return s.replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/"/g, "\\\"") + .replace(/:/g, "\\:"); + } + const content = "WIFI:T:" + authType + ";S:" + esc(ssid) + ";P:" + esc(password) + ";;"; + const shellArg = "'" + content.replace(/'/g, "'\\''") + "'"; + qrEncodeProcess.command = ["bash", "-c", + "printf '%s' " + shellArg + " | qrencode -o /tmp/axos_wifi_qr.png -s 8 -m 4"]; + qrEncodeProcess.running = true; + } + + property string localIp: "" + property string gatewayIp: "" + property string dnsServers: "" + property string publicIp: "" + property string netInfoError: "" + + // Process needs a full stop/start cycle to re-run; setting running=true + // while already true is a no-op + function _restartProcess(proc) { + if (proc.running) { + proc.running = false; + Qt.callLater(() => { proc.running = true; }); + } else { + proc.running = true; + } + } + + function fetchNetworkInfo() { + if (!root.hasActiveConnection) return; + root.netInfoError = ""; + root._restartProcess(localNetInfoProcess); + root._restartProcess(publicIpProcess); + } + + // Exposed so Wifi.qml can drive its refresh spinner without a cross-file Process id + readonly property bool fetchingNetworkInfo: localNetInfoProcess.running || publicIpProcess.running + + property bool dnsApplying: false + property bool _dnsReapplyQueued: false + property string dnsApplyError: "" + property string _dnsConnectionName: "" + property string _dnsDeviceName: "" + + // Operational state for the in-flight apply, not authoritative preference (Wifi.qml owns that) + property bool _pendingDnsEnabled: false + property string _pendingDnsProviderId: "cloudflare" + + readonly property var dnsProviders: [ + { id: "cloudflare", name: "Cloudflare", servers: "1.1.1.1,1.0.0.1" }, + { id: "google", name: "Google", servers: "8.8.8.8,8.8.4.4" }, + { id: "quad9", name: "Quad9", servers: "9.9.9.9,149.112.112.112" }, + { id: "cloudflare-malware", name: "Cloudflare (Security)",servers: "1.1.1.2,1.0.0.2" }, + { id: "opendns", name: "OpenDNS", servers: "208.67.222.222,208.67.220.220" }, + { id: "cloudflare-family", name: "Cloudflare (Family)", servers: "1.1.1.3,1.0.0.3" }, + { id: "opendns-family", name: "OpenDNS FamilyShield", servers: "208.67.222.123,208.67.220.123" } + ] + + // O(1) lookup instead of .find() at every call site + readonly property var dnsProviderMap: { + var map = {}; + for (var i = 0; i < root.dnsProviders.length; i++) { + map[root.dnsProviders[i].id] = root.dnsProviders[i]; + } + return map; + } + + function providerById(id) { + return root.dnsProviderMap[id] || root.dnsProviders[0]; + } + + function applyDnsSettings(enabled, providerId) { + if (!root.activeNetwork) return; + root._pendingDnsEnabled = enabled; + root._pendingDnsProviderId = providerId; + + if (root.dnsApplying) { + // Re-run once the in-flight apply finishes instead of dropping this change + root._dnsReapplyQueued = true; + return; + } + + root.dnsApplyError = ""; + root.dnsServers = enabled ? root.providerById(providerId).servers : "—"; + + root.dnsApplying = true; + root._dnsConnectionName = ""; + dnsConnectionNameProcess.running = true; + } + + function formatFrequency(freqRaw) { + const match = String(freqRaw).match(/\d+/); + if (!match) return String(freqRaw); + const freq = parseInt(match[0], 10); + if (freq >= 2400 && freq <= 2495) + return "2.4GHz, Channel " + ((freq === 2484) ? 14 : Math.round((freq - 2407) / 5)); + if (freq >= 5925 && freq <= 7125) + return "6GHz, Channel " + Math.round((freq - 5950) / 5); + if (freq >= 5000 && freq <= 5895) + return "5GHz, Channel " + Math.round((freq - 5000) / 5); + return freq + " MHz"; + } + + function detailValue(index) { + switch (index) { + case 0: return root.localIp; + case 1: return root.gatewayIp; + case 2: return (root.dnsServers || "").replace(/,\s*/g, ", "); // one entry per line when wrapped + case 3: return root.publicIp; + case 4: return root.activeNetwork ? root.formatFrequency(root.activeNetwork.frequency ?? "") : "—"; + case 5: return root.activeNetwork?.security || "—"; + } + return ""; + } + + readonly property var detailItems: [ + { label: "Local IP", icon: "lan", valueIdx: 0, isSensitive: true, wifiOnly: false }, + { label: "Gateway", icon: "router", valueIdx: 1, isSensitive: true, wifiOnly: false }, + { label: "DNS", icon: "dns", valueIdx: 2, isSensitive: false, wifiOnly: false }, + { label: "Public IP", icon: "public", valueIdx: 3, isSensitive: true, wifiOnly: false }, + { label: "Frequency", icon: "settings_input_antenna", valueIdx: 4, isSensitive: false, wifiOnly: true }, + { label: "Security", icon: "encrypted", valueIdx: 5, isSensitive: false, wifiOnly: true } + ] + + Component.onCompleted: { + Quickshell.execDetached(["mkdir", "-p", root.cacheDir]); + speedTestCacheFile.reload(); + connectionTypeProcess.running = true; + } + + Component.onDestruction: { + errorTimer.stop(); + connectionTypeProcess.running = false; + speedTestPingProcess.running = false; + speedTestDownloadProcess.running = false; + speedTestUploadProcess.running = false; + localNetInfoProcess.running = false; + publicIpProcess.running = false; + dnsConnectionNameProcess.running = false; + dnsModifyProcess.running = false; + qrPasswordProcess.running = false; + qrEncodeProcess.running = false; + } + + onRefreshTriggerChanged: { + if (root.hasActiveConnection) { + connectionTypeProcess.running = true; + } + } + + // Bumps refreshTrigger before and after the async Network update lands, so + // anything bound to it re-evaluates against the final settled state + function _refreshNetworkState(alsoFetchInfo) { + root.refreshTrigger++; + Qt.callLater(() => { + Network.updateNetworks?.(); + Network.updateActiveConnection?.(); + root.refreshTrigger++; + if (alsoFetchInfo) root.fetchNetworkInfo(); + }); + } + + Connections { + target: Network + + function onConnectionSucceeded(ssid) { + root.showConnectionError = false; + root.lastConnectionError = ""; + root.errorSsid = ""; + root._refreshNetworkState(true); + } + + function onConnectionFailed(ssid, error) { + root.lastConnectionError = error; + root.errorSsid = ssid; + root.showConnectionError = true; + errorTimer.restart(); + root._refreshNetworkState(false); + } + // onPasswordRequired is handled in Wifi.qml - it needs the network list's Repeater + } + + Timer { + id: errorTimer + interval: 5000 + onTriggered: root.showConnectionError = false + } + + FileView { + id: speedTestCacheFile + path: root.cacheDir + "/network.json" + printErrors: false + watchChanges: false + + onLoaded: { + try { + const parsed = JSON.parse(text()); + root.speedTestResults = (parsed && typeof parsed === "object") ? parsed : {}; + } catch (e) { + root.speedTestResults = {}; + } + } + + onLoadFailed: (error) => { + root.speedTestResults = {}; + } + } + + Process { + id: connectionTypeProcess + running: false + command: ["bash", "-c", "nmcli -t -f TYPE connection show --active 2>/dev/null"] + stdout: StdioCollector { + onStreamFinished: { + root.activeConnectionType = (text || "").trim(); + if (root.hasActiveConnection) { + root.fetchNetworkInfo(); + } + } + } + } + + Process { + id: speedTestPingProcess + running: false + command: ["bash", "-c", + "LC_ALL=C curl --max-time 10 -o /dev/null -s -w '%{time_connect}' 'https://speed.cloudflare.com/__down?bytes=0'"] + + stdout: StdioCollector { + onStreamFinished: { + const v = parseFloat(text); + if (!isNaN(v)) { + root.speedTestPingMs = v * 1000; + } + } + } + + onExited: (code) => { + if (code !== 0) { + root.speedTestError = "Couldn't reach the network (ping failed)"; + root.speedTestStage = "error"; + root.speedTestRunning = false; + root.cacheSpeedTestResult(root.speedTestSsid); + return; + } + root.speedTestStage = "download"; + speedTestDownloadProcess.running = true; + } + } + + Process { + id: speedTestDownloadProcess + running: false + command: ["bash", "-c", + "LC_ALL=C curl --max-time 20 -o /dev/null -s -w '%{speed_download}' 'https://speed.cloudflare.com/__down?bytes=25000000'"] + + stdout: StdioCollector { + onStreamFinished: { + const v = parseFloat(text); + if (!isNaN(v)) { + root.speedTestDownloadMbps = (v * 8) / 1000000; + } + } + } + + onExited: (code) => { + if (code !== 0) { + root.speedTestError = "Download test failed"; + root.speedTestStage = "error"; + root.speedTestRunning = false; + root.cacheSpeedTestResult(root.speedTestSsid); + return; + } + root.speedTestStage = "upload"; + speedTestUploadProcess.running = true; + } + } + + Process { + id: speedTestUploadProcess + running: false + command: ["bash", "-c", + "LC_ALL=C curl --max-time 20 -X POST -s -o /dev/null -w '%{speed_upload}' --data-binary @<(head -c 8000000 /dev/urandom) 'https://speed.cloudflare.com/__up'"] + + stdout: StdioCollector { + onStreamFinished: { + const v = parseFloat(text); + if (!isNaN(v)) { + root.speedTestUploadMbps = (v * 8) / 1000000; + } + } + } + + onExited: (code) => { + root.speedTestStage = (code !== 0) ? "error" : "done"; + root.speedTestRunning = false; + if (code !== 0) root.speedTestError = "Upload test failed"; + root.cacheSpeedTestResult(root.speedTestSsid); + } + } + + Process { + id: localNetInfoProcess + running: false + command: ["bash", "-c", + "ip -4 route get 1.1.1.1 2>/dev/null | head -n1; echo '||'; " + + "awk '/^nameserver/{print $2}' /etc/resolv.conf 2>/dev/null | paste -sd ',' -"] + + stdout: StdioCollector { + onStreamFinished: { + const raw = text || ""; + const parts = raw.split("||"); + const route = (parts[0] || "").trim(); + const dns = (parts[1] || "").trim(); + root.localIp = (route.match(/src\s+(\S+)/) || [])[1] || "—"; + root.gatewayIp = (route.match(/via\s+(\S+)/) || [])[1] || "—"; + // Only update DNS from resolv.conf when we are not actively applying + // and when custom DNS is not enabled (otherwise we keep the manually set value) + if (!root.dnsApplying && !Config.options.networking.dnsSwitch) { + root.dnsServers = dns.length > 0 ? dns : "—"; + } + } + } + + onExited: (code) => { if (code !== 0) root.netInfoError = "Couldn't read local network info"; } + } + + Process { + id: publicIpProcess + running: false + command: ["bash", "-c", "curl --max-time 6 -s https://api.ipify.org"] + + stdout: StdioCollector { + onStreamFinished: { + const v = (text || "").trim(); + root.publicIp = v.length > 0 ? v : "—"; + } + } + + onExited: (code) => { if (code !== 0) root.publicIp = "—"; } + } + + Process { + id: dnsConnectionNameProcess + running: false + command: ["bash", "-c", + "nmcli -t -f NAME,DEVICE,TYPE connection show --active 2>/dev/null | " + + "awk -F: '$3==\"802-11-wireless\"{print $1\"|\"$2; exit}'"] + + stdout: StdioCollector { + onStreamFinished: { + const parts = (text || "").trim().split("|"); + root._dnsConnectionName = parts[0] || ""; + root._dnsDeviceName = parts[1] || ""; + } + } + + onExited: (code) => { + if (code !== 0 || !root._dnsConnectionName || !root._dnsDeviceName) { + root.dnsApplyError = "Couldn't find the active connection"; + root.dnsApplying = false; + if (root._dnsReapplyQueued) { + root._dnsReapplyQueued = false; + root.applyDnsSettings(root._pendingDnsEnabled, root._pendingDnsProviderId); + } + return; + } + const name = root._dnsConnectionName.replace(/"/g, "\\\""); + const device = root._dnsDeviceName.replace(/"/g, "\\\""); + const reapply = `(nmcli device reapply "${device}" || nmcli connection up "${name}")`; + const cmd = root._pendingDnsEnabled + ? `nmcli connection modify "${name}" ipv4.ignore-auto-dns yes ipv4.dns "${root.providerById(root._pendingDnsProviderId).servers}" && ${reapply}` + : `nmcli connection modify "${name}" ipv4.ignore-auto-dns no ipv4.dns "" && ${reapply}`; + dnsModifyProcess.command = ["bash", "-c", cmd]; + dnsModifyProcess.running = true; + } + } + + Process { + id: dnsModifyProcess + running: false + command: ["bash", "-c", "true"] + + onExited: (code) => { + root.dnsApplying = false; + if (code !== 0) root.dnsApplyError = "Failed to apply DNS settings"; + Qt.callLater(root.fetchNetworkInfo); + if (root._dnsReapplyQueued) { + root._dnsReapplyQueued = false; + root.applyDnsSettings(root._pendingDnsEnabled, root._pendingDnsProviderId); + } + } + } + + Process { + id: qrPasswordProcess + running: false + command: ["bash", "-c", + "nmcli -s -g 802-11-wireless-security.psk connection show " + + "\"$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null | " + + "awk -F: '$2==\"802-11-wireless\"{print $1; exit}')\" 2>/dev/null"] + + stdout: StdioCollector { + onStreamFinished: { root._qrPassword = (text || "").trim(); } + } + + onExited: (code) => { + if (code !== 0 || !root._qrPassword) { + root.qrError = "Couldn't retrieve the stored WiFi password.\nEnsure NetworkManager has this connection saved with a password."; + root.qrGenerating = false; + return; + } + const sec = (root.activeNetwork?.security || "").toLowerCase(); + const authType = sec.includes("wep") ? "WEP" : "WPA"; + root._encodeQr(root.qrActiveSsid, root._qrPassword, authType); + root._qrPassword = ""; + } + } + + Process { + id: qrEncodeProcess + running: false + command: ["bash", "-c", "true"] + + onExited: (code) => { + root.qrGenerating = false; + if (code !== 0) { + root.qrError = "QR generation failed. Is 'qrencode' installed?\n(sudo pacman -S qrencode)"; + return; + } + root.qrImagePath = ""; + Qt.callLater(() => { root.qrImagePath = "file:///tmp/axos_wifi_qr.png"; }); + } + } +}