From 898e490114ca57020545a43ceb12bf24221d69a3 Mon Sep 17 00:00:00 2001 From: Armiel Pillay <125122987+Abscissa24@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:56:54 +0200 Subject: [PATCH 1/8] feat!: complete overhaul of network architecture and visual UI redesign - Implemented completely new, custom-developed core network features like speed test integration, signal strength, latency, custom dns-switching -etc. - Overhauled the user interface and visual design for a modernised look. - Optimised the code for smoothness and efficiency while sustaining all new and improved features --- src/share/sleex/modules/settings/Wifi.qml | 2455 +++++++++++++++++---- 1 file changed, 2016 insertions(+), 439 deletions(-) diff --git a/src/share/sleex/modules/settings/Wifi.qml b/src/share/sleex/modules/settings/Wifi.qml index 9a6cc431..d34b40fe 100644 --- a/src/share/sleex/modules/settings/Wifi.qml +++ b/src/share/sleex/modules/settings/Wifi.qml @@ -14,597 +14,2174 @@ import Sleex.Services ContentPage { id: root forceSingleColumn: true - // forceWidth: true - + // Connection error tracking property string lastConnectionError: "" property string errorSsid: "" property bool showConnectionError: false - - // UI refresh trigger + + // UI refresh trigger - bump to force ScriptModel/computed re-evaluation property int refreshTrigger: 0 - + + // View toggles + property bool showSensitiveInfo: false + property bool showConnectionDetails: true + property bool networkSearchVisible: false + property string searchText: "" + + // Currently active (connected) network, kept in sync with refreshTrigger + readonly property var activeNetwork: { + let _t = root.refreshTrigger; + const nets = Network.networks || []; + for (let i = 0; i < nets.length; i++) { + if (nets[i].active) return nets[i]; + } + return null; + } + + property string activeConnectionType: "" + + // nmcli reports "802-3-ethernet" for wired and "802-11-wireless" for WiFi. + // activeNetwork above only ever reflects WiFi, so on its own it can't tell + // us a wired connection is up - hasActiveConnection covers both. + readonly property bool hasWiredConnection: root.activeConnectionType.includes("ethernet") + readonly property bool hasActiveConnection: root.activeNetwork !== null || root.hasWiredConnection + + // Speed test state + property bool speedTestRunning: false + property string speedTestStage: "idle" + property real speedTestPingMs: -1 + property real speedTestDownloadMbps: -1 + property real speedTestUploadMbps: -1 + property string speedTestError: "" + + function startSpeedTest() { + if (root.speedTestRunning) return; + root.speedTestRunning = true; + root.speedTestStage = "ping"; + root.speedTestPingMs = -1; + root.speedTestDownloadMbps = -1; + root.speedTestUploadMbps = -1; + root.speedTestError = ""; + speedTestPingProcess.running = true; + } + + // QR code sharing state + 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; + } + + // Local/public network info (health dashboard) + property string localIp: "" + property string gatewayIp: "" + property string dnsServers: "" + property string publicIp: "" + property string netInfoError: "" + + // Improved refresh – restarts any running process to guarantee fresh data + function fetchNetworkInfo() { + if (!root.hasActiveConnection) return; + root.netInfoError = ""; + + // Kill any in-flight process and start fresh + if (localNetInfoProcess.running) { + localNetInfoProcess.running = false; + Qt.callLater(() => { localNetInfoProcess.running = true; }); + } else { + localNetInfoProcess.running = true; + } + + if (publicIpProcess.running) { + publicIpProcess.running = false; + Qt.callLater(() => { publicIpProcess.running = true; }); + } else { + publicIpProcess.running = true; + } + } + + // Custom DNS provider state + property bool customDnsEnabled: false + property string customDnsProviderId: "cloudflare" + property bool dnsApplying: false + property string dnsApplyError: "" + property string _dnsConnectionName: "" + property string _dnsDeviceName: "" + + // The network's own (non-overridden) DNS, captured live every time we read + // resolv.conf while custom DNS is off. Scoped to _defaultDnsSsid so a backup + // from one network can never leak into another after switching connections. + property string _defaultDnsBackup: "" + property string _defaultDnsSsid: "" + + // After disabling, NetworkManager's reapply doesn't roll resolv.conf back to + // the real DHCP DNS instantly - a read taken too soon can still show the + // just-disabled custom DNS. Track retries so we can wait it out instead of + // trusting (and caching) a stale value. + property int _dnsSettleRetries: 0 + readonly property var _allProviderDnsStrings: root.dnsProviders.map(p => p.servers) + + // Drop any cached default the moment the active network changes - it belongs + // to whichever SSID it was captured under and is meaningless anywhere else. + onActiveNetworkChanged: { + if (!root.activeNetwork || root._defaultDnsSsid !== root.activeNetwork.ssid) { + root._defaultDnsBackup = ""; + root._defaultDnsSsid = ""; + } + root._dnsSettleRetries = 0; + dnsSettleRetryTimer.stop(); + } + + 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 map instead of .find() everywhere + 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 currentDnsProvider() { + return root.dnsProviderMap[root.customDnsProviderId] || root.dnsProviders[0]; + } + + // Apply DNS settings – guarded against double‑apply, with UI instant update + function applyDnsSettings() { + if (!root.activeNetwork) return; + if (root.dnsApplying) return; // already applying + + root.dnsApplyError = ""; + root._dnsSettleRetries = 0; + dnsSettleRetryTimer.stop(); + const ssid = root.activeNetwork.ssid; + const haveBackupForThisNetwork = root._defaultDnsSsid === ssid && root._defaultDnsBackup !== ""; + + if (root.customDnsEnabled) { + // Safety net: normally the backup is kept fresh continuously by + // fetchNetworkInfo() (see localNetInfoProcess) while DNS isn't + // overridden. If we somehow don't have one yet for this network, + // grab whatever's showing right now as a best-effort fallback. + if (!haveBackupForThisNetwork && root.dnsServers !== "—") { + root._defaultDnsBackup = root.dnsServers; + root._defaultDnsSsid = ssid; + } + root.dnsServers = currentDnsProvider().servers; + } else { + // Restore instantly if we trust the cached value; otherwise show a + // pending state rather than leaving the old custom DNS on screen - + // fetchNetworkInfo() will fill in the real value once nmcli settles. + root.dnsServers = haveBackupForThisNetwork ? root._defaultDnsBackup : "—"; + } + + 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; + 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 } + ] + + // Frequency/Security only mean anything for a WiFi connection - drop them + // entirely over Ethernet rather than showing "—" placeholders. + readonly property var filteredDetailItems: { + var arr = []; + for (var i = 0; i < detailItems.length; ++i) { + const item = detailItems[i]; + if (item.wifiOnly && root.activeNetwork === null) continue; + if (!item.isSensitive || root.showSensitiveInfo) + arr.push(item); + } + return arr; + } + + function parseSavedSpeedString(str) { + if (!str) return -1; + const match = String(str).match(/[\d.]+/); + return match ? parseFloat(match[0]) : -1; + } + + Component.onCompleted: { + // Always check - this is how we discover a wired connection exists at + // all (activeNetwork only ever reflects WiFi). fetchNetworkInfo() is + // triggered from connectionTypeProcess's own handler below once we + // actually know whether something is connected. + connectionTypeProcess.running = true; + const savedProvider = Config.options.networking.dnsProvider; + if (savedProvider) root.customDnsProviderId = savedProvider; + root.customDnsEnabled = Config.options.networking.dnsSwitch || false; + root.showSensitiveInfo = Config.options.networking.sensitiveNetworkInfo || false; + root.showConnectionDetails = Config.options.networking.connectionDetails !== undefined + ? Config.options.networking.connectionDetails : true; + + const savedLat = Config.options.networking.latency; + const savedDown = Config.options.networking.downloadSpeed; + const savedUp = Config.options.networking.uploadSpeed; + + root.speedTestPingMs = parseSavedSpeedString(savedLat); + root.speedTestDownloadMbps = parseSavedSpeedString(savedDown); + root.speedTestUploadMbps = parseSavedSpeedString(savedUp); + + if (root.speedTestPingMs >= 0 || root.speedTestDownloadMbps >= 0 || root.speedTestUploadMbps >= 0) { + root.speedTestStage = "done"; + } + } + + // Stop any in-flight background work if the page is torn down mid-request - + // otherwise a speed test or DNS apply can keep a curl/nmcli process (and its + // infinite pulse animation) alive after navigating away. + Component.onDestruction: { + errorTimer.stop(); + dnsSettleRetryTimer.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; + } + } + + // Shared tail of the connection-succeeded/failed handlers below - re-asks the + // Network service for fresh state and forces a second ScriptModel re-evaluation + // once that state has actually landed (single source of truth, avoids drift + // between the two handlers). + function _refreshNetworkState(alsoFetchInfo) { + root.refreshTrigger++; + Qt.callLater(() => { + Network.updateNetworks?.(); + Network.updateActiveConnection?.(); + root.refreshTrigger++; + if (alsoFetchInfo) root.fetchNetworkInfo(); + }); + } + // 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++; - }); + root.errorSsid = ""; + root._refreshNetworkState(true); } - + function onConnectionFailed(ssid, error) { root.lastConnectionError = error; - root.errorSsid = ssid; + 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++; - }); + root._refreshNetworkState(false); } - + 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; - } + onTriggered: root.showConnectionError = false } + // Retries fetchNetworkInfo() a few times when a post-toggle DNS read still + // looks like the just-disabled custom DNS instead of the real default. + Timer { + id: dnsSettleRetryTimer + interval: 500 + onTriggered: root.fetchNetworkInfo() + } + 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(); + } + } + } + } - // Rectangle { - // Layout.fillWidth: true - // height: warnChildren.height + 40 - // color: "#40FF9800" - // radius: 6 + 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; + Config.options.networking.latency = root.speedTestPingMs.toFixed(0) + " ms"; + } + } + } - // RowLayout { - // id: warnChildren - // anchors.fill: parent - // anchors.margins: 10 + onExited: (code) => { + if (code !== 0) { + root.speedTestError = "Couldn't reach the network (ping failed)"; + root.speedTestStage = "error"; + root.speedTestRunning = false; + return; + } + root.speedTestStage = "download"; + speedTestDownloadProcess.running = true; + } + } - // Label { - // text: "🚧" - // font.pixelSize: 16 // Slightly smaller icon - // Layout.alignment: Qt.AlignVCenter - // rightPadding: 6 - // } + 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; + Config.options.networking.downloadSpeed = root.speedTestDownloadMbps.toFixed(1) + " Mbps"; + } + } + } - // 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" - // } - // } - // } + onExited: (code) => { + if (code !== 0) { + root.speedTestError = "Download test failed"; + root.speedTestStage = "error"; + root.speedTestRunning = false; + return; + } + root.speedTestStage = "upload"; + speedTestUploadProcess.running = true; + } + } - ContentSection { - title: "Wifi settings" - icon: "network_wifi" + 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; + Config.options.networking.uploadSpeed = root.speedTestUploadMbps.toFixed(1) + " Mbps"; + } + } + } - RowLayout { - spacing: 10 - uniformCellSizes: true + onExited: (code) => { + root.speedTestStage = (code !== 0) ? "error" : "done"; + root.speedTestRunning = false; + if (code !== 0) root.speedTestError = "Upload test failed"; + } + } - ConfigSwitch { - text: "Enabled" - checked: Network.wifiEnabled || false - onClicked: { - const newState = !checked; - // Toggle WiFi state - Network.toggleWifi(); - } - StyledToolTip { - text: Network.wifiEnabled ? "Click to disable WiFi" : "Click to enable WiFi" + 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] || "—"; + // DNS will be updated only if we are not in the middle of a custom DNS switch + // (the apply function has already set the instant value) + if (!root.dnsApplying) { + const value = dns.length > 0 ? dns : "—"; + + // If custom DNS is supposed to be off but this read still matches one + // of our known provider strings, NetworkManager hasn't rolled resolv.conf + // back to the real DHCP DNS yet. Don't trust or cache it - wait it out. + const looksLikeLeftoverCustomDns = !root.customDnsEnabled && + root._allProviderDnsStrings.indexOf(value) !== -1; + + if (looksLikeLeftoverCustomDns && root._dnsSettleRetries < 6) { + root._dnsSettleRetries++; + dnsSettleRetryTimer.restart(); + } else { + root._dnsSettleRetries = 0; + root.dnsServers = value; + if (!root.customDnsEnabled && root.activeNetwork) { + // Custom DNS is off, so whatever resolv.conf reports right now + // genuinely IS this network's default - keep the backup current + // so a future toggle-off can restore it instantly and correctly. + root._defaultDnsBackup = value; + root._defaultDnsSsid = root.activeNetwork.ssid; + } + } } + // else: keep the instant value until apply finishes, then fetchNetworkInfo will be called } } + + onExited: (code) => { if (code !== 0) root.netInfoError = "Couldn't read local network info"; } } - RowLayout { - spacing: 10 + Process { + id: publicIpProcess + running: false + command: ["bash", "-c", "curl --max-time 6 -s https://api.ipify.org"] - 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; + stdout: StdioCollector { + onStreamFinished: { + const v = (text || "").trim(); + root.publicIp = v.length > 0 ? v : "—"; } - color: Appearance.colors.colOnLayer0 - font.pixelSize: Appearance.font.pixelSize.huge } - RippleButton { - id: discoverBtn + onExited: (code) => { if (code !== 0) root.publicIp = "—"; } + } - visible: Network.wifiEnabled || false + 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; + 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.customDnsEnabled + ? `nmcli connection modify "${name}" ipv4.ignore-auto-dns yes ipv4.dns "${root.currentDnsProvider().servers}" && ${reapply}` + : `nmcli connection modify "${name}" ipv4.ignore-auto-dns no ipv4.dns "" && ${reapply}`; + dnsModifyProcess.command = ["bash", "-c", cmd]; + dnsModifyProcess.running = true; + } + } - contentItem: Rectangle { - id: discoverBtnBody - radius: Appearance.rounding.full - color: (Network.scanning || false) ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 - implicitWidth: height + Process { + id: dnsModifyProcess + running: false + command: ["bash", "-c", "true"] + + onExited: (code) => { + root.dnsApplying = false; + if (code !== 0) { + // Rollback – the apply failed, so the DNS setting did not change + root.customDnsEnabled = !root.customDnsEnabled; + Config.options.networking.dnsSwitch = root.customDnsEnabled; + root.dnsApplyError = "Failed to apply DNS settings"; + // Also revert the UI DNS to the real system state + Qt.callLater(root.fetchNetworkInfo); + return; + } + if (root.customDnsEnabled) + Config.options.networking.dnsProvider = root.customDnsProviderId; + // Refresh UI with actual values (will also overwrite the instant DNS with the real one) + Qt.callLater(root.fetchNetworkInfo); + } + } - MaterialSymbol { - id: scanIcon + 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"] - anchors.centerIn: parent - text: "refresh" - color: (Network.scanning || false) ? Appearance.m3colors.m3onSecondary : Appearance.m3colors.m3onSecondaryContainer - fill: (Network.scanning || false) ? 1 : 0 - } + 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 = ""; + } + } - MouseArea { - id: discoverArea - anchors.fill: parent - hoverEnabled: true - onClicked: Network.rescanWifi() + Process { + id: qrEncodeProcess + running: false + command: ["bash", "-c", "true"] - StyledToolTip { - extraVisibleCondition: discoverArea.containsMouse - text: "Discover new networks" - } + 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"; }); } } ContentSection { + title: "Wifi settings" + icon: "network_wifi" 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 + Layout.fillWidth: true + spacing: 18 + + ConfigSwitch { + text: "Enabled" + checked: Network.wifiEnabled || false + onClicked: Network.toggleWifi() + StyledToolTip { + text: Network.wifiEnabled ? "Click to disable WiFi" : "Click to enable WiFi" + } } - 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 + ConfigSwitch { + text: "Connection Details" + checked: root.showConnectionDetails + onClicked: { + root.showConnectionDetails = !root.showConnectionDetails; + Config.options.networking.connectionDetails = root.showConnectionDetails; + } + StyledToolTip { + text: root.showConnectionDetails + ? "Hide Connection Details" + : "Show Connection Details" + } } - } - StyledTextArea { - id: networkSearch - Layout.fillWidth: true - Layout.leftMargin: 16 - Layout.rightMargin: 16 - placeholderText: "Search networks" - visible: Network.wifiEnabled || false - } - - - - 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())); - } - return networks; + ConfigSwitch { + text: "Sensitive Info" + checked: root.showSensitiveInfo + onClicked: { + root.showSensitiveInfo = !root.showSensitiveInfo; + Config.options.networking.sensitiveNetworkInfo = root.showSensitiveInfo; + } + StyledToolTip { + text: root.showSensitiveInfo + ? "Hide sensitive network details" + : "Show sensitive network details" } } + } + } - RowLayout { - id: networkItem + Item { + Layout.fillWidth: true + readonly property real targetHeight: root.hasActiveConnection && root.showConnectionDetails + ? healthDashboard.implicitHeight : 0 + height: targetHeight + implicitHeight: targetHeight + clip: true + + Behavior on height { + NumberAnimation { duration: 180; easing.type: Easing.InOutQuad } + } - required property var modelData - readonly property bool isConnecting: Network.connectingToSsid === modelData.ssid - readonly property bool loading: networkItem.isConnecting + ContentSection { + id: healthDashboard + width: parent.width + visible: root.hasActiveConnection && root.showConnectionDetails + title: "Connection Details" + icon: "monitoring" - property bool expanded: false - + ColumnLayout { + Layout.fillWidth: true + spacing: 16 + RowLayout { + Layout.fillWidth: true + spacing: 12 + + MaterialSymbol { + id: connectionTypeIcon + Layout.alignment: Qt.AlignVCenter + text: root.activeConnectionType.includes("wireless") ? "wifi" : "settings_ethernet" + font.pixelSize: Appearance.font.pixelSize.title + color: Appearance.m3colors.m3primary + } - Layout.fillWidth: true - spacing: 10 + ColumnLayout { + spacing: 2 + + StyledText { + text: root.activeNetwork?.ssid || (root.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 + } + } + + 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: root.speedTestRunning ? 1 : 0 + + // Only tick while the dashboard is actually shown - avoids a + // perpetual repaint loop running behind a collapsed section. + SequentialAnimation on opacity { + running: root.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: root.speedTestRunning ? Qt.ArrowCursor : Qt.PointingHandCursor + enabled: !root.speedTestRunning + onClicked: root.startSpeedTest() + + StyledToolTip { + extraVisibleCondition: speedTestHeaderArea.containsMouse + text: root.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: (localNetInfoProcess.running || publicIpProcess.running) ? 1 : 0 + + RotationAnimation on rotation { + running: (localNetInfoProcess.running || publicIpProcess.running) && root.showConnectionDetails + loops: Animation.Infinite + from: 0; to: 360; duration: 900 + } + } + } + + MouseArea { + id: refreshInfoArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.fetchNetworkInfo() + + StyledToolTip { + extraVisibleCondition: refreshInfoArea.containsMouse + text: "Refresh connection info" + } + } + } + } Rectangle { - id: netRect Layout.fillWidth: true - implicitHeight: netCard.height + dropDownBox.height - radius: Appearance.rounding.small + height: 1 + color: Appearance.colors.colOutlineVariant + } - color: Appearance.colors.colLayer2 - - ColumnLayout { - width: parent.width - id: netCard + StyledText { + Layout.fillWidth: true + visible: root.netInfoError !== "" + text: root.netInfoError + color: Appearance.m3colors.m3error + font.pixelSize: Appearance.font.pixelSize.small + wrapMode: Text.WordWrap + } - RowLayout { - spacing: 10 - Layout.margins: 10 - - RowLayout { + RowLayout { + Layout.fillWidth: true + spacing: 12 + height: 100 + + Rectangle { + // Meaningless over Ethernet - hide rather than show a misleading 0% + visible: root.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 - MaterialSymbol { - text: Network.getNetworkIcon(networkItem.modelData.strength) - font.pixelSize: Appearance.font.pixelSize.title - color: Appearance.colors.colOnSecondaryContainer + Rectangle { + width: parent.width * Math.min(root.activeNetwork?.strength ?? 0, 100) / 100 + height: parent.height + radius: 2 + color: Appearance.m3colors.m3primary } + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: (root.activeNetwork?.strength ?? 0) + "%" + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: Appearance.m3colors.m3primary + } + } + } - MaterialSymbol { - visible: networkItem.modelData?.isSecure || false - text: "lock" - font.pixelSize: Appearance.font.pixelSize.larger - color: Appearance.colors.colOnSecondaryContainer - } + 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 color latencyColor: { + if (root.speedTestPingMs < 0) return Appearance.m3colors.m3outline; + return root.speedTestPingMs < 50 ? Appearance.m3colors.m3primary + : root.speedTestPingMs < 100 ? Appearance.m3colors.m3tertiary + : Appearance.m3colors.m3error; + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 6 + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: "timer" + font.pixelSize: 24 + color: parent.parent.latencyColor + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: "Latency" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + } + StyledText { + Layout.alignment: Qt.AlignHCenter + text: root.speedTestPingMs >= 0 + ? root.speedTestPingMs.toFixed(0) + " ms" + : (root.speedTestRunning && root.speedTestStage === "ping" ? "…" : "—") + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: root.speedTestPingMs >= 0 ? parent.parent.latencyColor : 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 + + 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: root.speedTestDownloadMbps >= 0 + ? root.speedTestDownloadMbps.toFixed(1) + " Mbps" + : (root.speedTestRunning && (root.speedTestStage === "ping" || root.speedTestStage === "download") ? "…" : "—") + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: root.speedTestDownloadMbps >= 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 + + 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: root.speedTestUploadMbps >= 0 + ? root.speedTestUploadMbps.toFixed(1) + " Mbps" + : (root.speedTestRunning ? "…" : "—") + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: root.speedTestUploadMbps >= 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 + implicitHeight: infoContent.implicitHeight + 24 + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + border.width: 1 + border.color: Appearance.colors.colOutlineVariant ColumnLayout { - id: cardTexts + id: infoContent + anchors.centerIn: parent + width: parent.width - 16 + spacing: 4 - 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 + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: modelData.icon + font.pixelSize: 20 + color: Appearance.m3colors.m3primary } - StyledText { - Layout.fillWidth: true - text: "Open Network" + Layout.alignment: Qt.AlignHCenter + text: modelData.label font.pixelSize: Appearance.font.pixelSize.small color: Appearance.colors.colSubtext - visible: !(networkItem.modelData?.isSecure || false) } - - // 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 + Layout.alignment: Qt.AlignHCenter + text: root.detailValue(modelData.valueIdx) + font.pixelSize: Appearance.font.pixelSize.large + font.weight: 600 + color: Appearance.colors.colOnLayer1 wrapMode: Text.WordWrap - visible: root.showConnectionError && root.errorSsid === networkItem.modelData.ssid + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true } } + } + } + } + } + } + } - RippleButton { - id: expandBtn - visible: networkItem.modelData?.isSecure || false - - contentItem: Rectangle { - id: expandBtnBody - radius: Appearance.rounding.full - color: Appearance.colors.colLayer2 - implicitWidth: height // makes it a perfect circle + Item { + Layout.fillWidth: true + readonly property real targetHeight: root.activeNetwork !== null + ? customDnsSection.implicitHeight : 0 + height: targetHeight + implicitHeight: targetHeight + clip: true + + Behavior on height { + NumberAnimation { duration: 180; easing.type: Easing.InOutQuad } + } - MaterialSymbol { - id: expandIcon + ContentSection { + id: customDnsSection + width: parent.width + visible: root.activeNetwork !== null + title: "Custom DNS" + icon: "dns" - 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 + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + + // Disable switch while DNS is being applied to prevent races + ConfigSwitch { + text: "Custom DNS" + checked: root.customDnsEnabled + enabled: !root.dnsApplying + onClicked: { + root.customDnsEnabled = !root.customDnsEnabled; + Config.options.networking.dnsSwitch = root.customDnsEnabled; + root.applyDnsSettings(); + } + StyledToolTip { + text: root.dnsApplying + ? "Applying DNS settings…" + : (root.customDnsEnabled + ? "Click to use this network's default DNS" + : "Click to use a custom DNS provider") + } + } - onClicked: networkItem.expanded = !networkItem.expanded + 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 - StyledToolTip { - extraVisibleCondition: expandArea.containsMouse - text: networkItem.expanded ? "Collapse" : "Expand" - } - } + StyledText { + text: modelData.group + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext } - 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, ""); + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Repeater { + model: modelData.ids + + delegate: Rectangle { + required property string modelData + required property int index + + // O(1) lookup via map + readonly property var provider: root.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 + } + + 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 } } } - } else { - // Unknown secure network - expand for password input - networkItem.expanded = true; + } + + 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 } } } } - } - } - - 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"; + + MouseArea { + anchors.fill: parent + enabled: !root.dnsApplying + cursorShape: Qt.PointingHandCursor + onClicked: { + root.customDnsProviderId = modelData; + root.applyDnsSettings(); + } } } - visible: parent.containsMouse || false } - } // Close Item container + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + visible: root.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: root.dnsApplyError + } + } + } + } + } + + ContentSection { + title: "Networks" + icon: "wifi" + + ColumnLayout { + Layout.fillWidth: true + spacing: 18 + + RowLayout { + Layout.fillWidth: true + spacing: 14 + + RowLayout { + Layout.fillWidth: true + visible: !root.networkSearchVisible + spacing: 10 + + 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: root.activeNetwork !== null + radius: Appearance.rounding.full + color: Appearance.colors.colLayer2 + implicitWidth: connectedPillRow.implicitWidth + 20 + implicitHeight: connectedPillRow.implicitHeight + 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: root.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 { + Layout.fillWidth: true + 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 + } + + 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 + } + } + + 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 = "" + } + + contentItem: MaterialSymbol { + anchors.centerIn: parent + iconSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnLayer2Disabled + text: "close" + } + } + } + } + + RippleButton { + id: discoverBtn + visible: Network.wifiEnabled || false + + contentItem: Rectangle { + radius: Appearance.rounding.full + color: (Network.scanning || false) ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 + implicitWidth: height + + MaterialSymbol { + anchors.centerIn: parent + text: "refresh" + color: (Network.scanning || false) + ? Appearance.m3colors.m3onSecondary + : Appearance.m3colors.m3onSecondaryContainer + fill: (Network.scanning || false) ? 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: Network.wifiEnabled || false + + 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); + } + } + + StyledToolTip { + extraVisibleCondition: searchToggleArea.containsMouse + text: root.networkSearchVisible ? "Hide search" : "Search networks" + } + } + } + } + + ColumnLayout { + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.topMargin: 12 + Layout.bottomMargin: 12 + visible: !(Network.wifiEnabled || false) + spacing: 14 + + MaterialSymbol { + Layout.alignment: Qt.AlignHCenter + text: "signal_wifi_off" + font.pixelSize: 48 + color: Appearance.colors.colOnLayer1 + } + + 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: Network.wifiEnabled || false + + Repeater { + id: networkRepeater + + model: ScriptModel { + id: networkModel + values: { + let _t = root.refreshTrigger; + 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 + readonly property bool isConnecting: Network.connectingToSsid === modelData.ssid + + property bool expanded: false + + Layout.fillWidth: true + spacing: 10 + + onExpandedChanged: { + if (!expanded) { + passwdInput.text = ""; + showButton.toggled = false; + } } Rectangle { - id: dropDownBox + id: netRect Layout.fillWidth: true - height: networkItem.expanded ? dropDownContent.implicitHeight + 16 : 0 - color: "transparent" - opacity: networkItem.expanded ? 1 : 0 - visible: height > 0 + implicitHeight: netCard.height + dropDownBox.height + radius: Appearance.rounding.small + color: Appearance.colors.colLayer2 + border.width: (networkItem.modelData?.active || false) ? 2 : 0 + border.color: Appearance.m3colors.m3primary - Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.InOutQuad } } - Behavior on opacity { NumberAnimation { duration: 150 } } + Behavior on border.width { 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 } + id: netCard + width: parent.width + RowLayout { + spacing: 16 + Layout.margins: 18 + RowLayout { + spacing: 6 - 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 + MaterialSymbol { + text: Network.getNetworkIcon(networkItem.modelData.strength) + font.pixelSize: Appearance.font.pixelSize.title + color: Appearance.colors.colOnSecondaryContainer + } + + MaterialSymbol { + visible: networkItem.modelData?.isSecure || false + text: "lock" + font.pixelSize: Appearance.font.pixelSize.larger + color: Appearance.colors.colOnSecondaryContainer + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + StyledText { 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 + 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 + } + + StyledText { + Layout.fillWidth: true + text: "Open Network" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.colors.colSubtext + visible: !(networkItem.modelData?.isSecure || false) + } + + 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 + } + } + + RippleButton { + id: expandBtn + visible: (networkItem.modelData?.isSecure || false) || (networkItem.modelData?.active || false) + + 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" } + } + } - 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 + 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 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: (mouse) => { + mouse.accepted = true; + const isActive = networkItem.modelData?.active || false; + const isSecure = networkItem.modelData?.isSecure || false; + const isKnown = networkItem.modelData?.isKnown || false; + + 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.modelData?.active + ? "Disconnect from network" + : networkItem.modelData?.isKnown + ? "Connect to known network" + : networkItem.modelData?.isSecure + ? "Click to enter password" + : "Click to connect to open network" + visible: parent.containsMouse || false + } } + } + } + } - 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 + 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 } } + + 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: root.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 } + } + } + + RowLayout { + spacing: 10 + visible: root.speedTestPingMs >= 0 || (root.speedTestRunning && root.speedTestStage === "ping") + 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: root.speedTestPingMs >= 0 ? root.speedTestPingMs.toFixed(0) + " ms" : (root.speedTestRunning && root.speedTestStage === "ping" ? "…" : "—") + 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" + RowLayout { + spacing: 10 + visible: root.speedTestDownloadMbps >= 0 || (root.speedTestRunning && (root.speedTestStage === "ping" || root.speedTestStage === "download")) + 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: root.speedTestDownloadMbps >= 0 ? root.speedTestDownloadMbps.toFixed(1) + " Mbps" : (root.speedTestRunning ? "…" : "—") + font.pixelSize: Appearance.font.pixelSize.small + } + } + } + + RowLayout { + spacing: 10 + visible: root.speedTestUploadMbps >= 0 || (root.speedTestRunning && root.speedTestStage === "upload") + 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: root.speedTestUploadMbps >= 0 ? root.speedTestUploadMbps.toFixed(1) + " Mbps" : (root.speedTestRunning ? "…" : "—") + font.pixelSize: Appearance.font.pixelSize.small + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 16 + spacing: 10 + visible: (networkItem.modelData?.isSecure || false) && + (!(networkItem.modelData?.isKnown || false) || + 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.modelData?.active || false) || (networkItem.modelData?.isKnown || false) + + Rectangle { Layout.fillWidth: true; height: 1; color: Appearance.colors.colOutlineVariant } + + RowLayout { + Layout.fillWidth: true + spacing: 14 + + RowLayout { + spacing: 6 + visible: networkItem.modelData?.active || false + + RippleButtonWithIcon { + materialIcon: "speed" + mainText: root.speedTestRunning + ? "Testing…" + : (root.speedTestStage === "done" || root.speedTestStage === "error") + ? "Test Again" + : "Speed Test" + enabled: !root.speedTestRunning + onClicked: root.startSpeedTest() + } - 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.preferredWidth: 6 + Layout.preferredHeight: 6 + Layout.alignment: Qt.AlignVCenter + radius: 3 + color: Appearance.m3colors.m3primary + visible: root.speedTestRunning + + // Scoped to this row's expanded state - a Repeater can + // have many delegates; no reason to animate the ones + // that aren't expanded/visible. + SequentialAnimation on opacity { + running: root.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.modelData?.active || false + enabled: true onClicked: { - Network.connectToNetwork(networkItem.modelData.ssid, passwdInput.text); - networkItem.expanded = false + if (root.qrGenerating) + return; + + if (root.qrActiveSsid === networkItem.modelData.ssid && + (root.qrImagePath !== "" || root.qrError !== "")) { + root.qrActiveSsid = ""; + root.qrImagePath = ""; + root.qrError = ""; + qrPanel.opacity = 0; + qrFadeInAnim.stop(); + } else { + root.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.modelData?.isKnown || false + onClicked: { + Network.forgetNetwork(networkItem.modelData.ssid); + networkItem.expanded = false; + } } + + Item { Layout.fillWidth: true } } + Item { + Layout.fillWidth: true + readonly property bool qrActive: root.qrActiveSsid === networkItem.modelData.ssid && + (networkItem.modelData?.active || false) && + (root.qrGenerating || root.qrImagePath !== "" || root.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: root.qrGenerating && + root.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 + + // Scoped to this delegate's ssid so a Repeater with + // multiple known/active networks doesn't animate + // hidden rows while one QR code is generating. + SequentialAnimation on opacity { + running: root.qrGenerating && root.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: root.qrGenerating && root.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: root.qrError !== "" && + root.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: root.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: root.qrImagePath !== "" && + root.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: root.qrImagePath + smooth: false + fillMode: Image.PreserveAspectFit + cache: false + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 8 + + StyledText { + Layout.fillWidth: true + text: root.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.modelData?.isSecure || false) ? "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 + } + } + } + } + } + } } } } } } } - } } } - Item { - implicitHeight: 24 - } + Item { implicitHeight: 24 } } From b1146d9571303e85477bfe2d7fc9b10e8fe1949a Mon Sep 17 00:00:00 2001 From: Armiel Pillay <125122987+Abscissa24@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:58:12 +0200 Subject: [PATCH 2/8] Enhance networking properties in Config.qml Added new properties for networking configuration. --- src/share/sleex/modules/common/Config.qml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/share/sleex/modules/common/Config.qml b/src/share/sleex/modules/common/Config.qml index eb1ff5d1..b4c551eb 100644 --- a/src/share/sleex/modules/common/Config.qml +++ b/src/share/sleex/modules/common/Config.qml @@ -216,6 +216,13 @@ 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 latency: "" + property string downloadSpeed: "" + property string uploadSpeed: "" + property string dnsProvider: "" } property JsonObject osd: JsonObject { From b43d5955587a1d81bcc48f68e70c93773b3b7cb8 Mon Sep 17 00:00:00 2001 From: Armiel Pillay <125122987+Abscissa24@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:20:01 +0200 Subject: [PATCH 3/8] Refactor network speed properties to JSON format Replaced latency, downloadSpeed, and uploadSpeed properties with a JSON-serialised map for speed test results. --- src/share/sleex/modules/common/Config.qml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/share/sleex/modules/common/Config.qml b/src/share/sleex/modules/common/Config.qml index b4c551eb..34e5e7f8 100644 --- a/src/share/sleex/modules/common/Config.qml +++ b/src/share/sleex/modules/common/Config.qml @@ -219,9 +219,11 @@ Singleton { property bool connectionDetails: true property bool sensitiveNetworkInfo: false property bool dnsSwitch: false - property string latency: "" - property string downloadSpeed: "" - property string uploadSpeed: "" + // JSON-serialised map of SSID -> { pingMs, downloadMbps, uploadMbps }. + // Keeping it per-SSID (rather than the old single latency/downloadSpeed/ + // uploadSpeed strings) means each network's last speed test result + // survives switching networks, reconnecting, and shell restarts. + property string speedTestResultsJson: "{}" property string dnsProvider: "" } From 830ab348cac41412f83a6be08ced6584105b54fa Mon Sep 17 00:00:00 2001 From: Armiel Pillay <125122987+Abscissa24@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:29:20 +0200 Subject: [PATCH 4/8] (fix) DNS tab overflow and enforce unique network stats per SSID --- src/share/sleex/modules/settings/Wifi.qml | 185 ++++++++++++++++------ 1 file changed, 136 insertions(+), 49 deletions(-) diff --git a/src/share/sleex/modules/settings/Wifi.qml b/src/share/sleex/modules/settings/Wifi.qml index d34b40fe..c0c28cd4 100644 --- a/src/share/sleex/modules/settings/Wifi.qml +++ b/src/share/sleex/modules/settings/Wifi.qml @@ -20,7 +20,7 @@ ContentPage { property string errorSsid: "" property bool showConnectionError: false - // UI refresh trigger - bump to force ScriptModel/computed re-evaluation + // UI refresh trigger and bump to force ScriptModel/computed re-evaluation property int refreshTrigger: 0 // View toggles @@ -43,20 +43,68 @@ ContentPage { // nmcli reports "802-3-ethernet" for wired and "802-11-wireless" for WiFi. // activeNetwork above only ever reflects WiFi, so on its own it can't tell - // us a wired connection is up - hasActiveConnection covers both. + // us a wired connection is up, but hasActiveConnection covers both. readonly property bool hasWiredConnection: root.activeConnectionType.includes("ethernet") readonly property bool hasActiveConnection: root.activeNetwork !== null || root.hasWiredConnection - // Speed test state + // Speed test state reflects whichever test is currently in-flight (or was + // most recently run). `speedTestSsid` says which network that is. 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: "" + + // Completed results, kept per-SSID and keyed by SSID -> { pingMs, downloadMbps, uploadMbps }. + 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; // reassign (not mutate) so bindings notice + try { + Config.options.networking.speedTestResultsJson = JSON.stringify(next); + } catch (e) { + // Non-fatal - worst case the result just won't survive a restart. + } + } + + // Resolves what should be shown for a given network right now: live + // in-progress numbers if that's the network currently being tested, + // otherwise whatever was cached for it (or -1 if it's never been tested). + 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; + } + // True while ssid is the one actually being tested right now (drives the + // "…" placeholders and the animated icon for that specific row only). + function speedTestIsLive(ssid) { + return root.speedTestRunning && root.speedTestSsid === ssid; + } + // True once ssid has a finished (done or error) test to show "Test Again" for. + 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; @@ -102,14 +150,14 @@ ContentPage { qrEncodeProcess.running = true; } - // Local/public network info (health dashboard) + // Local/public network info (connection details) property string localIp: "" property string gatewayIp: "" property string dnsServers: "" property string publicIp: "" property string netInfoError: "" - // Improved refresh – restarts any running process to guarantee fresh data + // Improved refresh by restarting any running process to guarantee fresh data function fetchNetworkInfo() { if (!root.hasActiveConnection) return; root.netInfoError = ""; @@ -185,7 +233,7 @@ ContentPage { return root.dnsProviderMap[root.customDnsProviderId] || root.dnsProviders[0]; } - // Apply DNS settings – guarded against double‑apply, with UI instant update + // Apply DNS settings is guarded against double‑apply, with UI instant update function applyDnsSettings() { if (!root.activeNetwork) return; if (root.dnsApplying) return; // already applying @@ -235,7 +283,11 @@ ContentPage { switch (index) { case 0: return root.localIp; case 1: return root.gatewayIp; - case 2: return root.dnsServers; + // Comma-separated with no spaces (e.g. "1.1.1.1,2606:4700:4700::1111") + // gives WordWrap nothing to break on, so a long DNS list just overflows + // its card. Insert a space after each comma so it wraps one entry per + // line instead. + case 2: return (root.dnsServers || "").replace(/,\s*/g, ", "); case 3: return root.publicIp; case 4: return root.activeNetwork ? root.formatFrequency(root.activeNetwork.frequency ?? "") : "—"; case 5: return root.activeNetwork?.security || "—"; @@ -265,10 +317,15 @@ ContentPage { return arr; } - function parseSavedSpeedString(str) { - if (!str) return -1; - const match = String(str).match(/[\d.]+/); - return match ? parseFloat(match[0]) : -1; + function loadSavedSpeedTestResults() { + const raw = Config.options.networking.speedTestResultsJson; + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return (parsed && typeof parsed === "object") ? parsed : {}; + } catch (e) { + return {}; + } } Component.onCompleted: { @@ -284,17 +341,10 @@ ContentPage { root.showConnectionDetails = Config.options.networking.connectionDetails !== undefined ? Config.options.networking.connectionDetails : true; - const savedLat = Config.options.networking.latency; - const savedDown = Config.options.networking.downloadSpeed; - const savedUp = Config.options.networking.uploadSpeed; - - root.speedTestPingMs = parseSavedSpeedString(savedLat); - root.speedTestDownloadMbps = parseSavedSpeedString(savedDown); - root.speedTestUploadMbps = parseSavedSpeedString(savedUp); - - if (root.speedTestPingMs >= 0 || root.speedTestDownloadMbps >= 0 || root.speedTestUploadMbps >= 0) { - root.speedTestStage = "done"; - } + // Restore every network's last speed test result (not just one "best + // guess" network) so switching back to a previously-tested network - + // even across a shell restart - still shows its numbers. + root.speedTestResults = root.loadSavedSpeedTestResults(); } // Stop any in-flight background work if the page is torn down mid-request - @@ -407,7 +457,6 @@ ContentPage { const v = parseFloat(text); if (!isNaN(v)) { root.speedTestPingMs = v * 1000; - Config.options.networking.latency = root.speedTestPingMs.toFixed(0) + " ms"; } } } @@ -417,6 +466,7 @@ ContentPage { root.speedTestError = "Couldn't reach the network (ping failed)"; root.speedTestStage = "error"; root.speedTestRunning = false; + root.cacheSpeedTestResult(root.speedTestSsid); return; } root.speedTestStage = "download"; @@ -435,7 +485,6 @@ ContentPage { const v = parseFloat(text); if (!isNaN(v)) { root.speedTestDownloadMbps = (v * 8) / 1000000; - Config.options.networking.downloadSpeed = root.speedTestDownloadMbps.toFixed(1) + " Mbps"; } } } @@ -445,6 +494,7 @@ ContentPage { root.speedTestError = "Download test failed"; root.speedTestStage = "error"; root.speedTestRunning = false; + root.cacheSpeedTestResult(root.speedTestSsid); return; } root.speedTestStage = "upload"; @@ -463,7 +513,6 @@ ContentPage { const v = parseFloat(text); if (!isNaN(v)) { root.speedTestUploadMbps = (v * 8) / 1000000; - Config.options.networking.uploadSpeed = root.speedTestUploadMbps.toFixed(1) + " Mbps"; } } } @@ -472,6 +521,7 @@ ContentPage { root.speedTestStage = (code !== 0) ? "error" : "done"; root.speedTestRunning = false; if (code !== 0) root.speedTestError = "Upload test failed"; + root.cacheSpeedTestResult(root.speedTestSsid); } } @@ -888,10 +938,14 @@ ContentPage { border.width: 1 border.color: Appearance.colors.colOutlineVariant + readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" + readonly property real pingValue: root.speedTestPing(targetSsid) + readonly property bool isLive: root.speedTestIsLive(targetSsid) + readonly property bool hasResult: pingValue >= 0 readonly property color latencyColor: { - if (root.speedTestPingMs < 0) return Appearance.m3colors.m3outline; - return root.speedTestPingMs < 50 ? Appearance.m3colors.m3primary - : root.speedTestPingMs < 100 ? Appearance.m3colors.m3tertiary + if (!hasResult) return Appearance.m3colors.m3outline; + return pingValue < 50 ? Appearance.m3colors.m3primary + : pingValue < 100 ? Appearance.m3colors.m3tertiary : Appearance.m3colors.m3error; } @@ -912,12 +966,12 @@ ContentPage { } StyledText { Layout.alignment: Qt.AlignHCenter - text: root.speedTestPingMs >= 0 - ? root.speedTestPingMs.toFixed(0) + " ms" - : (root.speedTestRunning && root.speedTestStage === "ping" ? "…" : "—") + text: parent.parent.hasResult + ? parent.parent.pingValue.toFixed(0) + " ms" + : (parent.parent.isLive && root.speedTestStage === "ping" ? "…" : "—") font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 - color: root.speedTestPingMs >= 0 ? parent.parent.latencyColor : Appearance.colors.colSubtext + color: parent.parent.hasResult ? parent.parent.latencyColor : Appearance.colors.colSubtext } } } @@ -936,6 +990,10 @@ ContentPage { border.width: 1 border.color: Appearance.colors.colOutlineVariant + readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" + readonly property real downloadValue: root.speedTestDownload(targetSsid) + readonly property bool isLive: root.speedTestIsLive(targetSsid) + ColumnLayout { anchors.centerIn: parent spacing: 6 @@ -953,12 +1011,12 @@ ContentPage { } StyledText { Layout.alignment: Qt.AlignHCenter - text: root.speedTestDownloadMbps >= 0 - ? root.speedTestDownloadMbps.toFixed(1) + " Mbps" - : (root.speedTestRunning && (root.speedTestStage === "ping" || root.speedTestStage === "download") ? "…" : "—") + text: parent.parent.downloadValue >= 0 + ? parent.parent.downloadValue.toFixed(1) + " Mbps" + : (parent.parent.isLive && (root.speedTestStage === "ping" || root.speedTestStage === "download") ? "…" : "—") font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 - color: root.speedTestDownloadMbps >= 0 ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext + color: parent.parent.downloadValue >= 0 ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext } } } @@ -971,6 +1029,10 @@ ContentPage { border.width: 1 border.color: Appearance.colors.colOutlineVariant + readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" + readonly property real uploadValue: root.speedTestUpload(targetSsid) + readonly property bool isLive: root.speedTestIsLive(targetSsid) + ColumnLayout { anchors.centerIn: parent spacing: 6 @@ -988,12 +1050,12 @@ ContentPage { } StyledText { Layout.alignment: Qt.AlignHCenter - text: root.speedTestUploadMbps >= 0 - ? root.speedTestUploadMbps.toFixed(1) + " Mbps" - : (root.speedTestRunning ? "…" : "—") + text: parent.parent.uploadValue >= 0 + ? parent.parent.uploadValue.toFixed(1) + " Mbps" + : (parent.parent.isLive ? "…" : "—") font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 - color: root.speedTestUploadMbps >= 0 ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext + color: parent.parent.uploadValue >= 0 ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext } } } @@ -1016,7 +1078,10 @@ ContentPage { 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 @@ -1042,13 +1107,20 @@ ContentPage { } StyledText { Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Layout.minimumWidth: 0 + // Bind width explicitly rather than trusting Layout.fillWidth alone - + // Text's implicit size is its natural, unwrapped width, so without a + // concrete width forced here the column can end up sized to fit the + // whole DNS string on one line and it overflows the card instead of + // ever actually wrapping. + width: infoContent.width text: root.detailValue(modelData.valueIdx) font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 color: Appearance.colors.colOnLayer1 - wrapMode: Text.WordWrap + wrapMode: Text.Wrap horizontalAlignment: Text.AlignHCenter - Layout.fillWidth: true } } } @@ -1701,42 +1773,57 @@ ContentPage { } RowLayout { + id: latencyRow + readonly property real pingValue: root.speedTestPing(networkItem.modelData.ssid) + readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) spacing: 10 - visible: root.speedTestPingMs >= 0 || (root.speedTestRunning && root.speedTestStage === "ping") + visible: pingValue >= 0 || (isLive && root.speedTestStage === "ping") 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: root.speedTestPingMs >= 0 ? root.speedTestPingMs.toFixed(0) + " ms" : (root.speedTestRunning && root.speedTestStage === "ping" ? "…" : "—") + text: latencyRow.pingValue >= 0 + ? latencyRow.pingValue.toFixed(0) + " ms" + : (latencyRow.isLive && root.speedTestStage === "ping" ? "…" : "—") font.pixelSize: Appearance.font.pixelSize.small } } } RowLayout { + id: downloadRow + readonly property real downloadValue: root.speedTestDownload(networkItem.modelData.ssid) + readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) spacing: 10 - visible: root.speedTestDownloadMbps >= 0 || (root.speedTestRunning && (root.speedTestStage === "ping" || root.speedTestStage === "download")) + visible: downloadValue >= 0 || (isLive && (root.speedTestStage === "ping" || root.speedTestStage === "download")) 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: root.speedTestDownloadMbps >= 0 ? root.speedTestDownloadMbps.toFixed(1) + " Mbps" : (root.speedTestRunning ? "…" : "—") + text: downloadRow.downloadValue >= 0 + ? downloadRow.downloadValue.toFixed(1) + " Mbps" + : (downloadRow.isLive ? "…" : "—") font.pixelSize: Appearance.font.pixelSize.small } } } RowLayout { + id: uploadRow + readonly property real uploadValue: root.speedTestUpload(networkItem.modelData.ssid) + readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) spacing: 10 - visible: root.speedTestUploadMbps >= 0 || (root.speedTestRunning && root.speedTestStage === "upload") + visible: uploadValue >= 0 || (isLive && root.speedTestStage === "upload") 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: root.speedTestUploadMbps >= 0 ? root.speedTestUploadMbps.toFixed(1) + " Mbps" : (root.speedTestRunning ? "…" : "—") + text: uploadRow.uploadValue >= 0 + ? uploadRow.uploadValue.toFixed(1) + " Mbps" + : (uploadRow.isLive ? "…" : "—") font.pixelSize: Appearance.font.pixelSize.small } } @@ -1881,7 +1968,7 @@ ContentPage { materialIcon: "speed" mainText: root.speedTestRunning ? "Testing…" - : (root.speedTestStage === "done" || root.speedTestStage === "error") + : root.speedTestIsDone(networkItem.modelData.ssid) ? "Test Again" : "Speed Test" enabled: !root.speedTestRunning From b31d7022ece7fa29f554c21892fda8e6a2408b0e Mon Sep 17 00:00:00 2001 From: Armiel Pillay <125122987+Abscissa24@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:46:53 +0200 Subject: [PATCH 5/8] Remove speedTestResultsJson property Removed the speedTestResultsJson property from Config.qml. --- src/share/sleex/modules/common/Config.qml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/share/sleex/modules/common/Config.qml b/src/share/sleex/modules/common/Config.qml index 34e5e7f8..5aff7d40 100644 --- a/src/share/sleex/modules/common/Config.qml +++ b/src/share/sleex/modules/common/Config.qml @@ -219,11 +219,6 @@ Singleton { property bool connectionDetails: true property bool sensitiveNetworkInfo: false property bool dnsSwitch: false - // JSON-serialised map of SSID -> { pingMs, downloadMbps, uploadMbps }. - // Keeping it per-SSID (rather than the old single latency/downloadSpeed/ - // uploadSpeed strings) means each network's last speed test result - // survives switching networks, reconnecting, and shell restarts. - property string speedTestResultsJson: "{}" property string dnsProvider: "" } From 06cfa73a733786a391c4f911f37c458645277159 Mon Sep 17 00:00:00 2001 From: Armiel Pillay <125122987+Abscissa24@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:50:42 +0200 Subject: [PATCH 6/8] Refactor network properties in Wifi.qml. Now uses C++ backend. Refactor Wifi.qml to simplify network property access and improve code clarity. Removed redundant active network retrieval logic and updated property bindings for better performance. --- src/share/sleex/modules/settings/Wifi.qml | 241 +++++++++++++--------- 1 file changed, 147 insertions(+), 94 deletions(-) diff --git a/src/share/sleex/modules/settings/Wifi.qml b/src/share/sleex/modules/settings/Wifi.qml index c0c28cd4..85b9cddd 100644 --- a/src/share/sleex/modules/settings/Wifi.qml +++ b/src/share/sleex/modules/settings/Wifi.qml @@ -4,7 +4,6 @@ import QtQuick.Layouts import Quickshell import Quickshell.Io import Quickshell.Widgets -import Quickshell.Bluetooth import qs.services import qs.modules.common import qs.modules.common.widgets @@ -29,24 +28,30 @@ ContentPage { property bool networkSearchVisible: false property string searchText: "" - // Currently active (connected) network, kept in sync with refreshTrigger - readonly property var activeNetwork: { - let _t = root.refreshTrigger; - const nets = Network.networks || []; - for (let i = 0; i < nets.length; i++) { - if (nets[i].active) return nets[i]; - } - return null; - } + // Currently active (connected) network, sourced directly from the C++ + // Network singleton - Network.active already tracks exactly this (kept in + // sync with NetworkManager's active access point via activeChanged), so + // there's no need to re-derive it by scanning root.networks here. + readonly property var activeNetwork: Network.active || null property string activeConnectionType: "" + // Resolved via a Process at startup since Quickshell.env() isn't available + // on this quickshell build (0.2.0.r131). Empty until ensureCacheDirProcess + // finishes, at which point speedTestCacheFile.path picks it up. + property string homeDir: "" + // nmcli reports "802-3-ethernet" for wired and "802-11-wireless" for WiFi. // activeNetwork above only ever reflects WiFi, so on its own it can't tell // us a wired connection is up, but hasActiveConnection covers both. readonly property bool hasWiredConnection: root.activeConnectionType.includes("ethernet") readonly property bool hasActiveConnection: root.activeNetwork !== null || root.hasWiredConnection + // Hoisted once instead of repeating "Network.x || false" at 6+ and 3+ + // call sites respectively throughout this file. + readonly property bool wifiEnabled: Network.wifiEnabled || false + readonly property bool wifiScanning: Network.scanning || false + // Speed test state reflects whichever test is currently in-flight (or was // most recently run). `speedTestSsid` says which network that is. property bool speedTestRunning: false @@ -69,8 +74,9 @@ ContentPage { uploadMbps: root.speedTestUploadMbps }; root.speedTestResults = next; // reassign (not mutate) so bindings notice + if (!root.homeDir) return; // path not resolved yet - can't persist safely try { - Config.options.networking.speedTestResultsJson = JSON.stringify(next); + speedTestCacheFile.setText(JSON.stringify(next, null, 2)); } catch (e) { // Non-fatal - worst case the result just won't survive a restart. } @@ -157,25 +163,25 @@ ContentPage { property string publicIp: "" property string netInfoError: "" + // Stops proc if it's currently running and restarts it on the next event + // loop turn (Process needs a full stop/start cycle to actually re-run, not + // just re-setting running=true while already true); otherwise starts it + // immediately. Shared by fetchNetworkInfo()'s two processes below. + function _restartProcess(proc) { + if (proc.running) { + proc.running = false; + Qt.callLater(() => { proc.running = true; }); + } else { + proc.running = true; + } + } + // Improved refresh by restarting any running process to guarantee fresh data function fetchNetworkInfo() { if (!root.hasActiveConnection) return; root.netInfoError = ""; - - // Kill any in-flight process and start fresh - if (localNetInfoProcess.running) { - localNetInfoProcess.running = false; - Qt.callLater(() => { localNetInfoProcess.running = true; }); - } else { - localNetInfoProcess.running = true; - } - - if (publicIpProcess.running) { - publicIpProcess.running = false; - Qt.callLater(() => { publicIpProcess.running = true; }); - } else { - publicIpProcess.running = true; - } + root._restartProcess(localNetInfoProcess); + root._restartProcess(publicIpProcess); } // Custom DNS provider state @@ -317,17 +323,6 @@ ContentPage { return arr; } - function loadSavedSpeedTestResults() { - const raw = Config.options.networking.speedTestResultsJson; - if (!raw) return {}; - try { - const parsed = JSON.parse(raw); - return (parsed && typeof parsed === "object") ? parsed : {}; - } catch (e) { - return {}; - } - } - Component.onCompleted: { // Always check - this is how we discover a wired connection exists at // all (activeNetwork only ever reflects WiFi). fetchNetworkInfo() is @@ -341,10 +336,10 @@ ContentPage { root.showConnectionDetails = Config.options.networking.connectionDetails !== undefined ? Config.options.networking.connectionDetails : true; - // Restore every network's last speed test result (not just one "best - // guess" network) so switching back to a previously-tested network - - // even across a shell restart - still shows its numbers. - root.speedTestResults = root.loadSavedSpeedTestResults(); + // Ensure ~/.cache/sleex/network exists, then load whatever speed + // test results are already on disk (FileView.onLoaded/onLoadFailed below + // populates root.speedTestResults once that's known). + ensureCacheDirProcess.running = true; } // Stop any in-flight background work if the page is torn down mid-request - @@ -363,6 +358,7 @@ ContentPage { dnsModifyProcess.running = false; qrPasswordProcess.running = false; qrEncodeProcess.running = false; + ensureCacheDirProcess.running = false; } onRefreshTriggerChanged: { @@ -432,6 +428,63 @@ ContentPage { onTriggered: root.fetchNetworkInfo() } + // Ensures ~/.cache/sleex/network exists on disk before we ever try + // to read or write network.json through the FileView below. + // FileView won't create missing parent directories itself, so this + // ordering matters - without it, the very first run (no ~/.cache/sleex + // at all yet) would fail to persist anything. Caches belong under + // XDG_CACHE_HOME (~/.cache), not the dotfile-style ~/.sleex. + Process { + id: ensureCacheDirProcess + running: false + // mkdir first, then print $HOME last so stdout is exactly the home + // dir (nothing else) for the collector below to pick up. + command: ["bash", "-c", "mkdir -p \"$HOME/.cache/sleex/network\" && printf '%s' \"$HOME\""] + + stdout: StdioCollector { + onStreamFinished: { + root.homeDir = (text || "").trim(); + } + } + + onExited: (code) => { + if (code !== 0 || !root.homeDir) { + // Couldn't resolve $HOME or create the dir - leave speedTestResults + // empty rather than guessing a path; results just won't persist. + return; + } + // path binding above already updated once homeDir changed; explicitly + // (re)load now that the file is guaranteed to be readable/creatable. + speedTestCacheFile.reload(); + } + } + + // Direct JSON-file persistence for per-SSID speed test results, replacing + // the old Config.options.networking.speedTestResultsJson approach (speed + // test results aren't really "config" and don't belong in the config file). + FileView { + id: speedTestCacheFile + path: root.homeDir ? (root.homeDir + "/.cache/sleex/network/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) => { + // File doesn't exist yet (first run) or another read error - start + // empty. The first cacheSpeedTestResult() call will create it via + // setText(). + root.speedTestResults = {}; + } + } + Process { id: connectionTypeProcess running: false @@ -694,10 +747,10 @@ ContentPage { ConfigSwitch { text: "Enabled" - checked: Network.wifiEnabled || false + checked: root.wifiEnabled onClicked: Network.toggleWifi() StyledToolTip { - text: Network.wifiEnabled ? "Click to disable WiFi" : "Click to enable WiFi" + text: root.wifiEnabled ? "Click to disable WiFi" : "Click to enable WiFi" } } @@ -940,14 +993,7 @@ ContentPage { readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" readonly property real pingValue: root.speedTestPing(targetSsid) - readonly property bool isLive: root.speedTestIsLive(targetSsid) readonly property bool hasResult: pingValue >= 0 - readonly property color latencyColor: { - if (!hasResult) return Appearance.m3colors.m3outline; - return pingValue < 50 ? Appearance.m3colors.m3primary - : pingValue < 100 ? Appearance.m3colors.m3tertiary - : Appearance.m3colors.m3error; - } ColumnLayout { anchors.centerIn: parent @@ -956,7 +1002,7 @@ ContentPage { Layout.alignment: Qt.AlignHCenter text: "timer" font.pixelSize: 24 - color: parent.parent.latencyColor + color: Appearance.m3colors.m3primary } StyledText { Layout.alignment: Qt.AlignHCenter @@ -968,10 +1014,10 @@ ContentPage { Layout.alignment: Qt.AlignHCenter text: parent.parent.hasResult ? parent.parent.pingValue.toFixed(0) + " ms" - : (parent.parent.isLive && root.speedTestStage === "ping" ? "…" : "—") + : "…" font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 - color: parent.parent.hasResult ? parent.parent.latencyColor : Appearance.colors.colSubtext + color: parent.parent.hasResult ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext } } } @@ -992,7 +1038,6 @@ ContentPage { readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" readonly property real downloadValue: root.speedTestDownload(targetSsid) - readonly property bool isLive: root.speedTestIsLive(targetSsid) ColumnLayout { anchors.centerIn: parent @@ -1013,7 +1058,7 @@ ContentPage { Layout.alignment: Qt.AlignHCenter text: parent.parent.downloadValue >= 0 ? parent.parent.downloadValue.toFixed(1) + " Mbps" - : (parent.parent.isLive && (root.speedTestStage === "ping" || root.speedTestStage === "download") ? "…" : "—") + : "…" font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 color: parent.parent.downloadValue >= 0 ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext @@ -1031,7 +1076,6 @@ ContentPage { readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" readonly property real uploadValue: root.speedTestUpload(targetSsid) - readonly property bool isLive: root.speedTestIsLive(targetSsid) ColumnLayout { anchors.centerIn: parent @@ -1052,7 +1096,7 @@ ContentPage { Layout.alignment: Qt.AlignHCenter text: parent.parent.uploadValue >= 0 ? parent.parent.uploadValue.toFixed(1) + " Mbps" - : (parent.parent.isLive ? "…" : "—") + : "…" font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 color: parent.parent.uploadValue >= 0 ? Appearance.m3colors.m3primary : Appearance.colors.colSubtext @@ -1436,20 +1480,20 @@ ContentPage { RippleButton { id: discoverBtn - visible: Network.wifiEnabled || false + visible: root.wifiEnabled contentItem: Rectangle { radius: Appearance.rounding.full - color: (Network.scanning || false) ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 + color: root.wifiScanning ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 implicitWidth: height MaterialSymbol { anchors.centerIn: parent text: "refresh" - color: (Network.scanning || false) + color: root.wifiScanning ? Appearance.m3colors.m3onSecondary : Appearance.m3colors.m3onSecondaryContainer - fill: (Network.scanning || false) ? 1 : 0 + fill: root.wifiScanning ? 1 : 0 } } @@ -1468,7 +1512,7 @@ ContentPage { RippleButton { id: searchToggleBtn - visible: Network.wifiEnabled || false + visible: root.wifiEnabled contentItem: Rectangle { radius: Appearance.rounding.full @@ -1512,7 +1556,7 @@ ContentPage { Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter Layout.topMargin: 12 Layout.bottomMargin: 12 - visible: !(Network.wifiEnabled || false) + visible: !root.wifiEnabled spacing: 14 MaterialSymbol { @@ -1534,7 +1578,7 @@ ContentPage { ColumnLayout { Layout.fillWidth: true spacing: 14 - visible: Network.wifiEnabled || false + visible: root.wifiEnabled Repeater { id: networkRepeater @@ -1555,10 +1599,16 @@ ContentPage { id: networkItem required property var modelData - readonly property bool isConnecting: Network.connectingToSsid === modelData.ssid - property bool expanded: false + // Hoisted once per row instead of repeating the same + // "modelData?.x || false" read across 17 separate + // bindings below - cheaper per row, and multiplies by + // however many networks are in the scan list. + 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 @@ -1575,7 +1625,7 @@ ContentPage { implicitHeight: netCard.height + dropDownBox.height radius: Appearance.rounding.small color: Appearance.colors.colLayer2 - border.width: (networkItem.modelData?.active || false) ? 2 : 0 + border.width: networkItem.isActive ? 2 : 0 border.color: Appearance.m3colors.m3primary Behavior on border.width { NumberAnimation { duration: 150 } } @@ -1598,7 +1648,7 @@ ContentPage { } MaterialSymbol { - visible: networkItem.modelData?.isSecure || false + visible: networkItem.isSecure text: "lock" font.pixelSize: Appearance.font.pixelSize.larger color: Appearance.colors.colOnSecondaryContainer @@ -1613,8 +1663,8 @@ ContentPage { Layout.fillWidth: true text: networkItem.modelData.ssid font.pixelSize: Appearance.font.pixelSize.large - font.weight: networkItem.modelData.active ? 500 : 400 - color: networkItem.modelData.active + font.weight: networkItem.isActive ? 500 : 400 + color: networkItem.isActive ? Appearance.m3colors.m3primary : Appearance.colors.colOnLayer1 } @@ -1624,7 +1674,7 @@ ContentPage { text: "Open Network" font.pixelSize: Appearance.font.pixelSize.small color: Appearance.colors.colSubtext - visible: !(networkItem.modelData?.isSecure || false) + visible: !networkItem.isSecure } StyledText { @@ -1639,7 +1689,7 @@ ContentPage { RippleButton { id: expandBtn - visible: (networkItem.modelData?.isSecure || false) || (networkItem.modelData?.active || false) + visible: networkItem.isSecure || networkItem.isActive contentItem: Rectangle { radius: Appearance.rounding.full @@ -1676,18 +1726,19 @@ ContentPage { id: toggleSwitch anchors.centerIn: parent scale: 0.80 - checked: networkItem.modelData?.active || false + checked: networkItem.isActive enabled: false } MouseArea { anchors.fill: parent + hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: (mouse) => { mouse.accepted = true; - const isActive = networkItem.modelData?.active || false; - const isSecure = networkItem.modelData?.isSecure || false; - const isKnown = networkItem.modelData?.isKnown || false; + const isActive = networkItem.isActive; + const isSecure = networkItem.isSecure; + const isKnown = networkItem.isKnown; if (isActive) { Network.disconnectFromNetwork(); @@ -1705,11 +1756,11 @@ ContentPage { } StyledToolTip { - text: networkItem.modelData?.active + text: networkItem.isActive ? "Disconnect from network" - : networkItem.modelData?.isKnown + : networkItem.isKnown ? "Connect to known network" - : networkItem.modelData?.isSecure + : networkItem.isSecure ? "Click to enter password" : "Click to connect to open network" visible: parent.containsMouse || false @@ -1777,7 +1828,7 @@ ContentPage { readonly property real pingValue: root.speedTestPing(networkItem.modelData.ssid) readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) spacing: 10 - visible: pingValue >= 0 || (isLive && root.speedTestStage === "ping") + visible: pingValue >= 0 || isLive MaterialSymbol { text: "timer"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } ColumnLayout { spacing: 2 @@ -1785,7 +1836,7 @@ ContentPage { StyledText { text: latencyRow.pingValue >= 0 ? latencyRow.pingValue.toFixed(0) + " ms" - : (latencyRow.isLive && root.speedTestStage === "ping" ? "…" : "—") + : "…" font.pixelSize: Appearance.font.pixelSize.small } } @@ -1796,7 +1847,7 @@ ContentPage { readonly property real downloadValue: root.speedTestDownload(networkItem.modelData.ssid) readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) spacing: 10 - visible: downloadValue >= 0 || (isLive && (root.speedTestStage === "ping" || root.speedTestStage === "download")) + visible: downloadValue >= 0 || isLive MaterialSymbol { text: "arrow_downward"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } ColumnLayout { spacing: 2 @@ -1804,7 +1855,7 @@ ContentPage { StyledText { text: downloadRow.downloadValue >= 0 ? downloadRow.downloadValue.toFixed(1) + " Mbps" - : (downloadRow.isLive ? "…" : "—") + : "…" font.pixelSize: Appearance.font.pixelSize.small } } @@ -1815,7 +1866,7 @@ ContentPage { readonly property real uploadValue: root.speedTestUpload(networkItem.modelData.ssid) readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) spacing: 10 - visible: uploadValue >= 0 || (isLive && root.speedTestStage === "upload") + visible: uploadValue >= 0 || isLive MaterialSymbol { text: "arrow_upward"; font.pixelSize: Appearance.font.pixelSize.larger; color: Appearance.colors.colOnSecondaryContainer } ColumnLayout { spacing: 2 @@ -1823,7 +1874,7 @@ ContentPage { StyledText { text: uploadRow.uploadValue >= 0 ? uploadRow.uploadValue.toFixed(1) + " Mbps" - : (uploadRow.isLive ? "…" : "—") + : "…" font.pixelSize: Appearance.font.pixelSize.small } } @@ -1834,8 +1885,8 @@ ContentPage { Layout.fillWidth: true Layout.topMargin: 16 spacing: 10 - visible: (networkItem.modelData?.isSecure || false) && - (!(networkItem.modelData?.isKnown || false) || + visible: networkItem.isSecure && + (!networkItem.isKnown || Network.hasConnectionFailed(networkItem.modelData.ssid)) Rectangle { Layout.fillWidth: true; height: 1; color: Appearance.colors.colOutlineVariant } @@ -1952,7 +2003,7 @@ ContentPage { Layout.fillWidth: true Layout.topMargin: 16 spacing: 14 - visible: (networkItem.modelData?.active || false) || (networkItem.modelData?.isKnown || false) + visible: networkItem.isActive || networkItem.isKnown Rectangle { Layout.fillWidth: true; height: 1; color: Appearance.colors.colOutlineVariant } @@ -1962,7 +2013,7 @@ ContentPage { RowLayout { spacing: 6 - visible: networkItem.modelData?.active || false + visible: networkItem.isActive RippleButtonWithIcon { materialIcon: "speed" @@ -1998,7 +2049,7 @@ ContentPage { RippleButtonWithIcon { materialIcon: "qr_code_2" mainText: "Share QR" - visible: networkItem.modelData?.active || false + visible: networkItem.isActive enabled: true onClicked: { if (root.qrGenerating) @@ -2023,7 +2074,7 @@ ContentPage { RippleButtonWithIcon { materialIcon: "delete" mainText: "Forget Network" - visible: networkItem.modelData?.isKnown || false + visible: networkItem.isKnown onClicked: { Network.forgetNetwork(networkItem.modelData.ssid); networkItem.expanded = false; @@ -2036,7 +2087,7 @@ ContentPage { Item { Layout.fillWidth: true readonly property bool qrActive: root.qrActiveSsid === networkItem.modelData.ssid && - (networkItem.modelData?.active || false) && + networkItem.isActive && (root.qrGenerating || root.qrImagePath !== "" || root.qrError !== "") readonly property real targetH: qrActive ? qrPanel.implicitHeight : 0 height: targetH @@ -2229,7 +2280,7 @@ ContentPage { spacing: 4 MaterialSymbol { - text: (networkItem.modelData?.isSecure || false) ? "lock" : "lock_open" + text: networkItem.isSecure ? "lock" : "lock_open" font.pixelSize: Appearance.font.pixelSize.small color: Appearance.m3colors.m3primary } @@ -2270,5 +2321,7 @@ ContentPage { } } - Item { implicitHeight: 24 } + Item { + implicitHeight: 24 + } } From 3bd831a9b97b83e039aed943c75d57642ceb5b04 Mon Sep 17 00:00:00 2001 From: Armiel Pillay <125122987+Abscissa24@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:06:50 +0200 Subject: [PATCH 7/8] (fix) Wifi.qml reserved exclusively for frontend --- src/share/sleex/modules/settings/Wifi.qml | 900 +++------------------- 1 file changed, 111 insertions(+), 789 deletions(-) diff --git a/src/share/sleex/modules/settings/Wifi.qml b/src/share/sleex/modules/settings/Wifi.qml index 85b9cddd..abaee1f8 100644 --- a/src/share/sleex/modules/settings/Wifi.qml +++ b/src/share/sleex/modules/settings/Wifi.qml @@ -5,403 +5,60 @@ import Quickshell import Quickshell.Io import Quickshell.Widgets 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 - // Connection error tracking - property string lastConnectionError: "" - property string errorSsid: "" - property bool showConnectionError: false - - // UI refresh trigger and bump to force ScriptModel/computed re-evaluation - property int refreshTrigger: 0 - - // View toggles property bool showSensitiveInfo: false property bool showConnectionDetails: true property bool networkSearchVisible: false property string searchText: "" - // Currently active (connected) network, sourced directly from the C++ - // Network singleton - Network.active already tracks exactly this (kept in - // sync with NetworkManager's active access point via activeChanged), so - // there's no need to re-derive it by scanning root.networks here. - readonly property var activeNetwork: Network.active || null - - property string activeConnectionType: "" - - // Resolved via a Process at startup since Quickshell.env() isn't available - // on this quickshell build (0.2.0.r131). Empty until ensureCacheDirProcess - // finishes, at which point speedTestCacheFile.path picks it up. - property string homeDir: "" - - // nmcli reports "802-3-ethernet" for wired and "802-11-wireless" for WiFi. - // activeNetwork above only ever reflects WiFi, so on its own it can't tell - // us a wired connection is up, but hasActiveConnection covers both. - readonly property bool hasWiredConnection: root.activeConnectionType.includes("ethernet") - readonly property bool hasActiveConnection: root.activeNetwork !== null || root.hasWiredConnection - - // Hoisted once instead of repeating "Network.x || false" at 6+ and 3+ - // call sites respectively throughout this file. - readonly property bool wifiEnabled: Network.wifiEnabled || false - readonly property bool wifiScanning: Network.scanning || false - - // Speed test state reflects whichever test is currently in-flight (or was - // most recently run). `speedTestSsid` says which network that is. - 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: "" - - // Completed results, kept per-SSID and keyed by SSID -> { pingMs, downloadMbps, uploadMbps }. - 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; // reassign (not mutate) so bindings notice - if (!root.homeDir) return; // path not resolved yet - can't persist safely - try { - speedTestCacheFile.setText(JSON.stringify(next, null, 2)); - } catch (e) { - // Non-fatal - worst case the result just won't survive a restart. - } - } - - // Resolves what should be shown for a given network right now: live - // in-progress numbers if that's the network currently being tested, - // otherwise whatever was cached for it (or -1 if it's never been tested). - 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; - } - // True while ssid is the one actually being tested right now (drives the - // "…" placeholders and the animated icon for that specific row only). - function speedTestIsLive(ssid) { - return root.speedTestRunning && root.speedTestSsid === ssid; - } - // True once ssid has a finished (done or error) test to show "Test Again" for. - 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; - } - - // QR code sharing state - 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; - } - - // Local/public network info (connection details) - property string localIp: "" - property string gatewayIp: "" - property string dnsServers: "" - property string publicIp: "" - property string netInfoError: "" - - // Stops proc if it's currently running and restarts it on the next event - // loop turn (Process needs a full stop/start cycle to actually re-run, not - // just re-setting running=true while already true); otherwise starts it - // immediately. Shared by fetchNetworkInfo()'s two processes below. - function _restartProcess(proc) { - if (proc.running) { - proc.running = false; - Qt.callLater(() => { proc.running = true; }); - } else { - proc.running = true; - } - } - - // Improved refresh by restarting any running process to guarantee fresh data - function fetchNetworkInfo() { - if (!root.hasActiveConnection) return; - root.netInfoError = ""; - root._restartProcess(localNetInfoProcess); - root._restartProcess(publicIpProcess); - } - - // Custom DNS provider state property bool customDnsEnabled: false property string customDnsProviderId: "cloudflare" - property bool dnsApplying: false - property string dnsApplyError: "" - property string _dnsConnectionName: "" - property string _dnsDeviceName: "" - - // The network's own (non-overridden) DNS, captured live every time we read - // resolv.conf while custom DNS is off. Scoped to _defaultDnsSsid so a backup - // from one network can never leak into another after switching connections. - property string _defaultDnsBackup: "" - property string _defaultDnsSsid: "" - - // After disabling, NetworkManager's reapply doesn't roll resolv.conf back to - // the real DHCP DNS instantly - a read taken too soon can still show the - // just-disabled custom DNS. Track retries so we can wait it out instead of - // trusting (and caching) a stale value. - property int _dnsSettleRetries: 0 - readonly property var _allProviderDnsStrings: root.dnsProviders.map(p => p.servers) - - // Drop any cached default the moment the active network changes - it belongs - // to whichever SSID it was captured under and is meaningless anywhere else. - onActiveNetworkChanged: { - if (!root.activeNetwork || root._defaultDnsSsid !== root.activeNetwork.ssid) { - root._defaultDnsBackup = ""; - root._defaultDnsSsid = ""; - } - root._dnsSettleRetries = 0; - dnsSettleRetryTimer.stop(); - } - 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 map instead of .find() everywhere - 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 currentDnsProvider() { - return root.dnsProviderMap[root.customDnsProviderId] || root.dnsProviders[0]; - } - - // Apply DNS settings is guarded against double‑apply, with UI instant update - function applyDnsSettings() { - if (!root.activeNetwork) return; - if (root.dnsApplying) return; // already applying - - root.dnsApplyError = ""; - root._dnsSettleRetries = 0; - dnsSettleRetryTimer.stop(); - const ssid = root.activeNetwork.ssid; - const haveBackupForThisNetwork = root._defaultDnsSsid === ssid && root._defaultDnsBackup !== ""; - - if (root.customDnsEnabled) { - // Safety net: normally the backup is kept fresh continuously by - // fetchNetworkInfo() (see localNetInfoProcess) while DNS isn't - // overridden. If we somehow don't have one yet for this network, - // grab whatever's showing right now as a best-effort fallback. - if (!haveBackupForThisNetwork && root.dnsServers !== "—") { - root._defaultDnsBackup = root.dnsServers; - root._defaultDnsSsid = ssid; - } - root.dnsServers = currentDnsProvider().servers; - } else { - // Restore instantly if we trust the cached value; otherwise show a - // pending state rather than leaving the old custom DNS on screen - - // fetchNetworkInfo() will fill in the real value once nmcli settles. - root.dnsServers = haveBackupForThisNetwork ? root._defaultDnsBackup : "—"; - } - - 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; - // Comma-separated with no spaces (e.g. "1.1.1.1,2606:4700:4700::1111") - // gives WordWrap nothing to break on, so a long DNS list just overflows - // its card. Insert a space after each comma so it wraps one entry per - // line instead. - case 2: return (root.dnsServers || "").replace(/,\s*/g, ", "); - 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 } - ] - - // Frequency/Security only mean anything for a WiFi connection - drop them - // entirely over Ethernet rather than showing "—" placeholders. readonly property var filteredDetailItems: { var arr = []; - for (var i = 0; i < detailItems.length; ++i) { - const item = detailItems[i]; - if (item.wifiOnly && root.activeNetwork === null) continue; + 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); } return arr; } - Component.onCompleted: { - // Always check - this is how we discover a wired connection exists at - // all (activeNetwork only ever reflects WiFi). fetchNetworkInfo() is - // triggered from connectionTypeProcess's own handler below once we - // actually know whether something is connected. - connectionTypeProcess.running = true; - const savedProvider = Config.options.networking.dnsProvider; - if (savedProvider) root.customDnsProviderId = savedProvider; - root.customDnsEnabled = Config.options.networking.dnsSwitch || false; + function _syncViewTogglesFromConfig() { root.showSensitiveInfo = Config.options.networking.sensitiveNetworkInfo || false; root.showConnectionDetails = Config.options.networking.connectionDetails !== undefined ? Config.options.networking.connectionDetails : true; - - // Ensure ~/.cache/sleex/network exists, then load whatever speed - // test results are already on disk (FileView.onLoaded/onLoadFailed below - // populates root.speedTestResults once that's known). - ensureCacheDirProcess.running = true; + const savedProvider = Config.options.networking.dnsProvider; + if (savedProvider) root.customDnsProviderId = savedProvider; + root.customDnsEnabled = Config.options.networking.dnsSwitch || false; } - // Stop any in-flight background work if the page is torn down mid-request - - // otherwise a speed test or DNS apply can keep a curl/nmcli process (and its - // infinite pulse animation) alive after navigating away. - Component.onDestruction: { - errorTimer.stop(); - dnsSettleRetryTimer.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; - ensureCacheDirProcess.running = false; - } + Component.onCompleted: root._syncViewTogglesFromConfig() - onRefreshTriggerChanged: { - if (root.hasActiveConnection) { - connectionTypeProcess.running = true; + // 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(); } } - // Shared tail of the connection-succeeded/failed handlers below - re-asks the - // Network service for fresh state and forces a second ScriptModel re-evaluation - // once that state has actually landed (single source of truth, avoids drift - // between the two handlers). - function _refreshNetworkState(alsoFetchInfo) { - root.refreshTrigger++; - Qt.callLater(() => { - Network.updateNetworks?.(); - Network.updateActiveConnection?.(); - root.refreshTrigger++; - if (alsoFetchInfo) root.fetchNetworkInfo(); - }); - } - - // Network connection result handlers 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); - } - function onPasswordRequired(ssid) { - // Security type changed - expand the network for password input for (let i = 0; i < networkRepeater.count; i++) { const item = networkRepeater.itemAt(i); if (item?.modelData?.ssid === ssid) { @@ -409,331 +66,7 @@ ContentPage { break; } } - root.refreshTrigger++; - } - } - - // Timer to auto-hide connection errors - Timer { - id: errorTimer - interval: 5000 - onTriggered: root.showConnectionError = false - } - - // Retries fetchNetworkInfo() a few times when a post-toggle DNS read still - // looks like the just-disabled custom DNS instead of the real default. - Timer { - id: dnsSettleRetryTimer - interval: 500 - onTriggered: root.fetchNetworkInfo() - } - - // Ensures ~/.cache/sleex/network exists on disk before we ever try - // to read or write network.json through the FileView below. - // FileView won't create missing parent directories itself, so this - // ordering matters - without it, the very first run (no ~/.cache/sleex - // at all yet) would fail to persist anything. Caches belong under - // XDG_CACHE_HOME (~/.cache), not the dotfile-style ~/.sleex. - Process { - id: ensureCacheDirProcess - running: false - // mkdir first, then print $HOME last so stdout is exactly the home - // dir (nothing else) for the collector below to pick up. - command: ["bash", "-c", "mkdir -p \"$HOME/.cache/sleex/network\" && printf '%s' \"$HOME\""] - - stdout: StdioCollector { - onStreamFinished: { - root.homeDir = (text || "").trim(); - } - } - - onExited: (code) => { - if (code !== 0 || !root.homeDir) { - // Couldn't resolve $HOME or create the dir - leave speedTestResults - // empty rather than guessing a path; results just won't persist. - return; - } - // path binding above already updated once homeDir changed; explicitly - // (re)load now that the file is guaranteed to be readable/creatable. - speedTestCacheFile.reload(); - } - } - - // Direct JSON-file persistence for per-SSID speed test results, replacing - // the old Config.options.networking.speedTestResultsJson approach (speed - // test results aren't really "config" and don't belong in the config file). - FileView { - id: speedTestCacheFile - path: root.homeDir ? (root.homeDir + "/.cache/sleex/network/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) => { - // File doesn't exist yet (first run) or another read error - start - // empty. The first cacheSpeedTestResult() call will create it via - // setText(). - 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] || "—"; - // DNS will be updated only if we are not in the middle of a custom DNS switch - // (the apply function has already set the instant value) - if (!root.dnsApplying) { - const value = dns.length > 0 ? dns : "—"; - - // If custom DNS is supposed to be off but this read still matches one - // of our known provider strings, NetworkManager hasn't rolled resolv.conf - // back to the real DHCP DNS yet. Don't trust or cache it - wait it out. - const looksLikeLeftoverCustomDns = !root.customDnsEnabled && - root._allProviderDnsStrings.indexOf(value) !== -1; - - if (looksLikeLeftoverCustomDns && root._dnsSettleRetries < 6) { - root._dnsSettleRetries++; - dnsSettleRetryTimer.restart(); - } else { - root._dnsSettleRetries = 0; - root.dnsServers = value; - if (!root.customDnsEnabled && root.activeNetwork) { - // Custom DNS is off, so whatever resolv.conf reports right now - // genuinely IS this network's default - keep the backup current - // so a future toggle-off can restore it instantly and correctly. - root._defaultDnsBackup = value; - root._defaultDnsSsid = root.activeNetwork.ssid; - } - } - } - // else: keep the instant value until apply finishes, then fetchNetworkInfo will be called - } - } - - 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; - 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.customDnsEnabled - ? `nmcli connection modify "${name}" ipv4.ignore-auto-dns yes ipv4.dns "${root.currentDnsProvider().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) { - // Rollback – the apply failed, so the DNS setting did not change - root.customDnsEnabled = !root.customDnsEnabled; - Config.options.networking.dnsSwitch = root.customDnsEnabled; - root.dnsApplyError = "Failed to apply DNS settings"; - // Also revert the UI DNS to the real system state - Qt.callLater(root.fetchNetworkInfo); - return; - } - if (root.customDnsEnabled) - Config.options.networking.dnsProvider = root.customDnsProviderId; - // Refresh UI with actual values (will also overwrite the instant DNS with the real one) - Qt.callLater(root.fetchNetworkInfo); - } - } - - 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"; }); + Services.Network.bumpRefresh(); } } @@ -747,10 +80,10 @@ ContentPage { ConfigSwitch { text: "Enabled" - checked: root.wifiEnabled + checked: Services.Network.wifiEnabled onClicked: Network.toggleWifi() StyledToolTip { - text: root.wifiEnabled ? "Click to disable WiFi" : "Click to enable WiFi" + text: Services.Network.wifiEnabled ? "Click to disable WiFi" : "Click to enable WiFi" } } @@ -759,7 +92,11 @@ ContentPage { checked: root.showConnectionDetails onClicked: { root.showConnectionDetails = !root.showConnectionDetails; - Config.options.networking.connectionDetails = root.showConnectionDetails; + try { + Config.setNestedValue("networking.connectionDetails", root.showConnectionDetails); + } catch (e) { + console.log("[Wifi] Failed to persist connectionDetails:", e); + } } StyledToolTip { text: root.showConnectionDetails @@ -773,7 +110,11 @@ ContentPage { checked: root.showSensitiveInfo onClicked: { root.showSensitiveInfo = !root.showSensitiveInfo; - Config.options.networking.sensitiveNetworkInfo = root.showSensitiveInfo; + try { + Config.setNestedValue("networking.sensitiveNetworkInfo", root.showSensitiveInfo); + } catch (e) { + console.log("[Wifi] Failed to persist sensitiveNetworkInfo:", e); + } } StyledToolTip { text: root.showSensitiveInfo @@ -786,7 +127,7 @@ ContentPage { Item { Layout.fillWidth: true - readonly property real targetHeight: root.hasActiveConnection && root.showConnectionDetails + readonly property real targetHeight: Services.Network.hasActiveConnection && root.showConnectionDetails ? healthDashboard.implicitHeight : 0 height: targetHeight implicitHeight: targetHeight @@ -799,7 +140,7 @@ ContentPage { ContentSection { id: healthDashboard width: parent.width - visible: root.hasActiveConnection && root.showConnectionDetails + visible: Services.Network.hasActiveConnection && root.showConnectionDetails title: "Connection Details" icon: "monitoring" @@ -814,7 +155,7 @@ ContentPage { MaterialSymbol { id: connectionTypeIcon Layout.alignment: Qt.AlignVCenter - text: root.activeConnectionType.includes("wireless") ? "wifi" : "settings_ethernet" + text: Services.Network.activeConnectionType.includes("wireless") ? "wifi" : "settings_ethernet" font.pixelSize: Appearance.font.pixelSize.title color: Appearance.m3colors.m3primary } @@ -823,7 +164,7 @@ ContentPage { spacing: 2 StyledText { - text: root.activeNetwork?.ssid || (root.hasWiredConnection ? "Wired Connection" : "") + text: Services.Network.activeNetwork?.ssid || (Services.Network.hasWiredConnection ? "Wired Connection" : "") font.pixelSize: Appearance.font.pixelSize.large font.weight: 500 color: Appearance.m3colors.m3primary @@ -849,12 +190,10 @@ ContentPage { anchors.centerIn: parent text: "speed" color: Appearance.m3colors.m3onSecondaryContainer - fill: root.speedTestRunning ? 1 : 0 + fill: Services.Network.speedTestRunning ? 1 : 0 - // Only tick while the dashboard is actually shown - avoids a - // perpetual repaint loop running behind a collapsed section. SequentialAnimation on opacity { - running: root.speedTestRunning && root.showConnectionDetails + 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 } @@ -866,13 +205,13 @@ ContentPage { id: speedTestHeaderArea anchors.fill: parent hoverEnabled: true - cursorShape: root.speedTestRunning ? Qt.ArrowCursor : Qt.PointingHandCursor - enabled: !root.speedTestRunning - onClicked: root.startSpeedTest() + cursorShape: Services.Network.speedTestRunning ? Qt.ArrowCursor : Qt.PointingHandCursor + enabled: !Services.Network.speedTestRunning + onClicked: Services.Network.startSpeedTest() StyledToolTip { extraVisibleCondition: speedTestHeaderArea.containsMouse - text: root.speedTestRunning ? "Testing…" : "Perform a network speed test\nUses the Cloudflare provider" + text: Services.Network.speedTestRunning ? "Testing…" : "Perform a network speed test\nUses the Cloudflare provider" } } } @@ -889,10 +228,10 @@ ContentPage { anchors.centerIn: parent text: "refresh" color: Appearance.m3colors.m3onSecondaryContainer - fill: (localNetInfoProcess.running || publicIpProcess.running) ? 1 : 0 + fill: Services.Network.fetchingNetworkInfo ? 1 : 0 RotationAnimation on rotation { - running: (localNetInfoProcess.running || publicIpProcess.running) && root.showConnectionDetails + running: Services.Network.fetchingNetworkInfo && root.showConnectionDetails loops: Animation.Infinite from: 0; to: 360; duration: 900 } @@ -904,7 +243,7 @@ ContentPage { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.fetchNetworkInfo() + onClicked: Services.Network.fetchNetworkInfo() StyledToolTip { extraVisibleCondition: refreshInfoArea.containsMouse @@ -922,8 +261,8 @@ ContentPage { StyledText { Layout.fillWidth: true - visible: root.netInfoError !== "" - text: root.netInfoError + visible: Services.Network.netInfoError !== "" + text: Services.Network.netInfoError color: Appearance.m3colors.m3error font.pixelSize: Appearance.font.pixelSize.small wrapMode: Text.WordWrap @@ -935,8 +274,7 @@ ContentPage { height: 100 Rectangle { - // Meaningless over Ethernet - hide rather than show a misleading 0% - visible: root.activeNetwork !== null + visible: Services.Network.activeNetwork !== null Layout.fillWidth: true Layout.fillHeight: true radius: Appearance.rounding.small @@ -967,7 +305,7 @@ ContentPage { Layout.alignment: Qt.AlignHCenter Rectangle { - width: parent.width * Math.min(root.activeNetwork?.strength ?? 0, 100) / 100 + width: parent.width * Math.min(Services.Network.activeNetwork?.strength ?? 0, 100) / 100 height: parent.height radius: 2 color: Appearance.m3colors.m3primary @@ -975,7 +313,7 @@ ContentPage { } StyledText { Layout.alignment: Qt.AlignHCenter - text: (root.activeNetwork?.strength ?? 0) + "%" + text: (Services.Network.activeNetwork?.strength ?? 0) + "%" font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 color: Appearance.m3colors.m3primary @@ -991,8 +329,8 @@ ContentPage { border.width: 1 border.color: Appearance.colors.colOutlineVariant - readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" - readonly property real pingValue: root.speedTestPing(targetSsid) + 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 { @@ -1036,8 +374,8 @@ ContentPage { border.width: 1 border.color: Appearance.colors.colOutlineVariant - readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" - readonly property real downloadValue: root.speedTestDownload(targetSsid) + readonly property string targetSsid: Services.Network.activeNetwork ? Services.Network.activeNetwork.ssid : "" + readonly property real downloadValue: Services.Network.speedTestDownload(targetSsid) ColumnLayout { anchors.centerIn: parent @@ -1074,8 +412,8 @@ ContentPage { border.width: 1 border.color: Appearance.colors.colOutlineVariant - readonly property string targetSsid: root.activeNetwork ? root.activeNetwork.ssid : "" - readonly property real uploadValue: root.speedTestUpload(targetSsid) + readonly property string targetSsid: Services.Network.activeNetwork ? Services.Network.activeNetwork.ssid : "" + readonly property real uploadValue: Services.Network.speedTestUpload(targetSsid) ColumnLayout { anchors.centerIn: parent @@ -1153,13 +491,8 @@ ContentPage { Layout.alignment: Qt.AlignHCenter Layout.fillWidth: true Layout.minimumWidth: 0 - // Bind width explicitly rather than trusting Layout.fillWidth alone - - // Text's implicit size is its natural, unwrapped width, so without a - // concrete width forced here the column can end up sized to fit the - // whole DNS string on one line and it overflows the card instead of - // ever actually wrapping. width: infoContent.width - text: root.detailValue(modelData.valueIdx) + text: Services.Network.detailValue(modelData.valueIdx) font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 color: Appearance.colors.colOnLayer1 @@ -1176,7 +509,7 @@ ContentPage { Item { Layout.fillWidth: true - readonly property real targetHeight: root.activeNetwork !== null + readonly property real targetHeight: Services.Network.activeNetwork !== null ? customDnsSection.implicitHeight : 0 height: targetHeight implicitHeight: targetHeight @@ -1189,7 +522,7 @@ ContentPage { ContentSection { id: customDnsSection width: parent.width - visible: root.activeNetwork !== null + visible: Services.Network.activeNetwork !== null title: "Custom DNS" icon: "dns" @@ -1197,18 +530,17 @@ ContentPage { Layout.fillWidth: true spacing: 14 - // Disable switch while DNS is being applied to prevent races ConfigSwitch { text: "Custom DNS" checked: root.customDnsEnabled - enabled: !root.dnsApplying + enabled: !Services.Network.dnsApplying onClicked: { root.customDnsEnabled = !root.customDnsEnabled; Config.options.networking.dnsSwitch = root.customDnsEnabled; - root.applyDnsSettings(); + Services.Network.applyDnsSettings(root.customDnsEnabled, root.customDnsProviderId); } StyledToolTip { - text: root.dnsApplying + text: Services.Network.dnsApplying ? "Applying DNS settings…" : (root.customDnsEnabled ? "Click to use this network's default DNS" @@ -1250,8 +582,7 @@ ContentPage { required property string modelData required property int index - // O(1) lookup via map - readonly property var provider: root.dnsProviderMap[modelData] ?? null + readonly property var provider: Services.Network.dnsProviderMap[modelData] ?? null readonly property bool isSelected: root.customDnsProviderId === modelData readonly property string primaryIp: provider?.servers.split(",")[0] ?? "" @@ -1308,11 +639,12 @@ ContentPage { MouseArea { anchors.fill: parent - enabled: !root.dnsApplying + enabled: !Services.Network.dnsApplying cursorShape: Qt.PointingHandCursor onClicked: { root.customDnsProviderId = modelData; - root.applyDnsSettings(); + Config.options.networking.dnsProvider = modelData; + Services.Network.applyDnsSettings(root.customDnsEnabled, modelData); } } } @@ -1325,7 +657,7 @@ ContentPage { RowLayout { Layout.fillWidth: true spacing: 8 - visible: root.dnsApplyError !== "" + visible: Services.Network.dnsApplyError !== "" MaterialSymbol { text: "error_outline" @@ -1338,7 +670,7 @@ ContentPage { wrapMode: Text.WordWrap font.pixelSize: Appearance.font.pixelSize.small color: Appearance.m3colors.m3error - text: root.dnsApplyError + text: Services.Network.dnsApplyError } } } @@ -1373,7 +705,7 @@ ContentPage { } Rectangle { - visible: root.activeNetwork !== null + visible: Services.Network.activeNetwork !== null radius: Appearance.rounding.full color: Appearance.colors.colLayer2 implicitWidth: connectedPillRow.implicitWidth + 20 @@ -1391,7 +723,7 @@ ContentPage { color: Appearance.m3colors.m3primary SequentialAnimation on opacity { - running: root.activeNetwork !== null + 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 } @@ -1480,20 +812,20 @@ ContentPage { RippleButton { id: discoverBtn - visible: root.wifiEnabled + visible: Services.Network.wifiEnabled contentItem: Rectangle { radius: Appearance.rounding.full - color: root.wifiScanning ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 + color: Services.Network.wifiScanning ? Appearance.m3colors.m3primary : Appearance.colors.colLayer2 implicitWidth: height MaterialSymbol { anchors.centerIn: parent text: "refresh" - color: root.wifiScanning + color: Services.Network.wifiScanning ? Appearance.m3colors.m3onSecondary : Appearance.m3colors.m3onSecondaryContainer - fill: root.wifiScanning ? 1 : 0 + fill: Services.Network.wifiScanning ? 1 : 0 } } @@ -1512,7 +844,7 @@ ContentPage { RippleButton { id: searchToggleBtn - visible: root.wifiEnabled + visible: Services.Network.wifiEnabled contentItem: Rectangle { radius: Appearance.rounding.full @@ -1556,7 +888,7 @@ ContentPage { Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter Layout.topMargin: 12 Layout.bottomMargin: 12 - visible: !root.wifiEnabled + visible: !Services.Network.wifiEnabled spacing: 14 MaterialSymbol { @@ -1578,7 +910,7 @@ ContentPage { ColumnLayout { Layout.fillWidth: true spacing: 14 - visible: root.wifiEnabled + visible: Services.Network.wifiEnabled Repeater { id: networkRepeater @@ -1586,7 +918,7 @@ ContentPage { model: ScriptModel { id: networkModel values: { - let _t = root.refreshTrigger; + 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)); @@ -1601,10 +933,6 @@ ContentPage { required property var modelData property bool expanded: false - // Hoisted once per row instead of repeating the same - // "modelData?.x || false" read across 17 separate - // bindings below - cheaper per row, and multiplies by - // however many networks are in the scan list. readonly property bool isActive: modelData?.active || false readonly property bool isSecure: modelData?.isSecure || false readonly property bool isKnown: modelData?.isKnown || false @@ -1679,11 +1007,11 @@ ContentPage { StyledText { Layout.fillWidth: true - text: "Failed to connect: " + root.lastConnectionError + text: "Failed to connect: " + Services.Network.lastConnectionError font.pixelSize: Appearance.font.pixelSize.small color: Appearance.m3colors.m3error wrapMode: Text.WordWrap - visible: root.showConnectionError && root.errorSsid === networkItem.modelData.ssid + visible: Services.Network.showConnectionError && Services.Network.errorSsid === networkItem.modelData.ssid } } @@ -1809,7 +1137,7 @@ ContentPage { ColumnLayout { spacing: 2 StyledText { text: "Frequency"; font.pixelSize: Appearance.font.pixelSize.small; color: Appearance.colors.colSubtext } - StyledText { text: root.formatFrequency(networkItem.modelData.frequency); font.pixelSize: Appearance.font.pixelSize.small } + StyledText { text: Services.Network.formatFrequency(networkItem.modelData.frequency); font.pixelSize: Appearance.font.pixelSize.small } } } @@ -1825,8 +1153,8 @@ ContentPage { RowLayout { id: latencyRow - readonly property real pingValue: root.speedTestPing(networkItem.modelData.ssid) - readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) + 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 } @@ -1844,8 +1172,8 @@ ContentPage { RowLayout { id: downloadRow - readonly property real downloadValue: root.speedTestDownload(networkItem.modelData.ssid) - readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) + 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 } @@ -1863,8 +1191,8 @@ ContentPage { RowLayout { id: uploadRow - readonly property real uploadValue: root.speedTestUpload(networkItem.modelData.ssid) - readonly property bool isLive: root.speedTestIsLive(networkItem.modelData.ssid) + 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 } @@ -2017,13 +1345,13 @@ ContentPage { RippleButtonWithIcon { materialIcon: "speed" - mainText: root.speedTestRunning + mainText: Services.Network.speedTestRunning ? "Testing…" - : root.speedTestIsDone(networkItem.modelData.ssid) + : Services.Network.speedTestIsDone(networkItem.modelData.ssid) ? "Test Again" : "Speed Test" - enabled: !root.speedTestRunning - onClicked: root.startSpeedTest() + enabled: !Services.Network.speedTestRunning + onClicked: Services.Network.startSpeedTest() } Rectangle { @@ -2032,13 +1360,10 @@ ContentPage { Layout.alignment: Qt.AlignVCenter radius: 3 color: Appearance.m3colors.m3primary - visible: root.speedTestRunning + visible: Services.Network.speedTestRunning - // Scoped to this row's expanded state - a Repeater can - // have many delegates; no reason to animate the ones - // that aren't expanded/visible. SequentialAnimation on opacity { - running: root.speedTestRunning && networkItem.expanded + 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 } @@ -2052,19 +1377,19 @@ ContentPage { visible: networkItem.isActive enabled: true onClicked: { - if (root.qrGenerating) + if (Services.Network.qrGenerating) return; - if (root.qrActiveSsid === networkItem.modelData.ssid && - (root.qrImagePath !== "" || root.qrError !== "")) { - root.qrActiveSsid = ""; - root.qrImagePath = ""; - root.qrError = ""; + 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 { - root.generateQrCode(networkItem.modelData.ssid, - networkItem.modelData.security || ""); + Services.Network.generateQrCode(networkItem.modelData.ssid, + networkItem.modelData.security || ""); qrPanel.opacity = 0; qrFadeInAnim.restart(); } @@ -2086,9 +1411,9 @@ ContentPage { Item { Layout.fillWidth: true - readonly property bool qrActive: root.qrActiveSsid === networkItem.modelData.ssid && + readonly property bool qrActive: Services.Network.qrActiveSsid === networkItem.modelData.ssid && networkItem.isActive && - (root.qrGenerating || root.qrImagePath !== "" || root.qrError !== "") + (Services.Network.qrGenerating || Services.Network.qrImagePath !== "" || Services.Network.qrError !== "") readonly property real targetH: qrActive ? qrPanel.implicitHeight : 0 height: targetH implicitHeight: targetH @@ -2115,8 +1440,8 @@ ContentPage { implicitHeight: 76 radius: Appearance.rounding.small clip: true - visible: root.qrGenerating && - root.qrActiveSsid === networkItem.modelData.ssid + 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) @@ -2134,11 +1459,8 @@ ContentPage { font.pixelSize: 30 color: Appearance.m3colors.m3primary - // Scoped to this delegate's ssid so a Repeater with - // multiple known/active networks doesn't animate - // hidden rows while one QR code is generating. SequentialAnimation on opacity { - running: root.qrGenerating && root.qrActiveSsid === networkItem.modelData.ssid + 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 } @@ -2178,7 +1500,7 @@ ContentPage { color: Appearance.m3colors.m3primary SequentialAnimation on x { - running: root.qrGenerating && root.qrActiveSsid === networkItem.modelData.ssid + 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 } } @@ -2190,8 +1512,8 @@ ContentPage { Layout.fillWidth: true implicitHeight: qrErrorRow.implicitHeight + 24 radius: Appearance.rounding.small - visible: root.qrError !== "" && - root.qrActiveSsid === networkItem.modelData.ssid + 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) @@ -2212,7 +1534,7 @@ ContentPage { } StyledText { Layout.fillWidth: true - text: root.qrError + text: Services.Network.qrError font.pixelSize: Appearance.font.pixelSize.small color: Appearance.m3colors.m3error wrapMode: Text.WordWrap @@ -2224,8 +1546,8 @@ ContentPage { Layout.fillWidth: true implicitHeight: qrReadyRow.implicitHeight + 32 radius: Appearance.rounding.small - visible: root.qrImagePath !== "" && - root.qrActiveSsid === networkItem.modelData.ssid + 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) @@ -2245,7 +1567,7 @@ ContentPage { Image { anchors { fill: parent; margins: 8 } - source: root.qrImagePath + source: Services.Network.qrImagePath smooth: false fillMode: Image.PreserveAspectFit cache: false @@ -2259,7 +1581,7 @@ ContentPage { StyledText { Layout.fillWidth: true - text: root.qrActiveSsid + text: Services.Network.qrActiveSsid font.pixelSize: Appearance.font.pixelSize.large font.weight: 600 color: Appearance.m3colors.m3primary From 0359acec9cf9d7d8b779835f22bf0e2b11d8d921 Mon Sep 17 00:00:00 2001 From: Armiel Pillay <125122987+Abscissa24@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:10:00 +0200 Subject: [PATCH 8/8] Add Network singleton service for connection management This file defines a singleton component for managing network connections, speed tests, and DNS settings in a Qt application. --- src/share/sleex/services/Network.qml | 552 +++++++++++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 src/share/sleex/services/Network.qml 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"; }); + } + } +}