pragma Singleton import QtQuick import Quickshell import Quickshell.Io import qs.Commons import qs.Services.System import qs.Services.UI Singleton { id: root // Core state property var networks: ({}) property bool scanning: false property bool connecting: false property string connectingTo: "" property string lastError: "" property bool ethernetConnected: false // Each item: { ifname: string, state: string, connected: bool } property var ethernetInterfaces: ([]) // Active Ethernet connection details property var activeEthernetDetails: ({}) property string activeEthernetIf: "" property bool ethernetDetailsLoading: false property double activeEthernetDetailsTimestamp: 0 // Keep same TTL policy for both kinds of links property int activeEthernetDetailsTtlMs: 5000 property string disconnectingFrom: "" property string forgettingNetwork: "" property string networkConnectivity: "unknown" property bool internetConnectivity: true property bool ignoreScanResults: false property bool scanPending: false // Active Wi‑Fi connection details (for info panel) property var activeWifiDetails: ({}) property string activeWifiIf: "" property bool detailsLoading: false property double activeWifiDetailsTimestamp: 0 // Cache TTL to avoid spamming nmcli/iw on rapid toggles property int activeWifiDetailsTtlMs: 5000 // Persistent cache property string cacheFile: Settings.cacheDir + "network.json" readonly property string cachedLastConnected: cacheAdapter.lastConnected readonly property var cachedNetworks: cacheAdapter.knownNetworks // Cache file handling FileView { id: cacheFileView path: root.cacheFile printErrors: false JsonAdapter { id: cacheAdapter property var knownNetworks: ({}) property string lastConnected: "" } onLoadFailed: { cacheAdapter.knownNetworks = ({}); cacheAdapter.lastConnected = ""; } } Connections { target: Settings.data.network function onWifiEnabledChanged() { if (Settings.data.network.wifiEnabled) { if (!BluetoothService.airplaneModeToggled) { ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.enabled"), "wifi"); } // Perform a scan to update the UI delayedScanTimer.interval = 3000; delayedScanTimer.restart(); } else { if (!BluetoothService.airplaneModeToggled) { ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.disabled"), "wifi-off"); } // Clear networks so the widget icon changes root.networks = ({}); } } } Component.onCompleted: { Logger.i("Network", "Service started"); if (ProgramCheckerService.nmcliAvailable) { syncWifiState(); scan(); // Prime ethernet state immediately so UI can reflect wired status on startup ethernetStateProcess.running = true; refreshActiveWifiDetails(); refreshActiveEthernetDetails(); } } // Start initial checks when nmcli becomes available Connections { target: ProgramCheckerService function onNmcliAvailableChanged() { if (ProgramCheckerService.nmcliAvailable) { syncWifiState(); scan(); // Refresh ethernet status as soon as nmcli becomes available ethernetStateProcess.running = true; // Also refresh details so panels get info without waiting for timers refreshActiveWifiDetails(); refreshActiveEthernetDetails(); } } } // Save cache with debounce Timer { id: saveDebounce interval: 1000 onTriggered: cacheFileView.writeAdapter() } // Refresh details for the currently active Wi‑Fi link function refreshActiveWifiDetails() { const now = Date.now(); // If we're already fetching, don't start a new one if (detailsLoading) return; // Use cached details if they are fresh if (activeWifiIf && activeWifiDetails && (now - activeWifiDetailsTimestamp) < activeWifiDetailsTtlMs) return; detailsLoading = true; wifiDeviceListProcess.running = true; } function saveCache() { saveDebounce.restart(); } // Delayed scan timer Timer { id: delayedScanTimer interval: 7000 onTriggered: scan() } // Ethernet check timer // Runs every 30s if nmcli is available Timer { id: ethernetCheckTimer interval: 30000 running: ProgramCheckerService.nmcliAvailable repeat: true onTriggered: ethernetStateProcess.running = true } // Refresh details for the currently active Ethernet link function refreshActiveEthernetDetails() { const now = Date.now(); if (ethernetDetailsLoading) return; if (!root.ethernetConnected) { // Link is down: keep the selected interface so UI can still show its info as disconnected // Only clear details to avoid showing stale IP/speed/etc. root.activeEthernetDetails = ({}); root.activeEthernetDetailsTimestamp = now; return; } // If we have fresh details for the same iface, skip if (activeEthernetIf && activeEthernetDetails && (now - activeEthernetDetailsTimestamp) < activeEthernetDetailsTtlMs) return; ethernetDetailsLoading = true; ethernetDeviceListProcess.running = true; } // Internet connectivity check timer // Runs every 15s if nmcli is available Timer { id: connectivityCheckTimer interval: 15000 running: ProgramCheckerService.nmcliAvailable repeat: true onTriggered: connectivityCheckProcess.running = true } // Core functions function syncWifiState() { if (!ProgramCheckerService.nmcliAvailable) return; wifiStateProcess.running = true; } function setWifiEnabled(enabled) { if (!ProgramCheckerService.nmcliAvailable) return; Settings.data.network.wifiEnabled = enabled; wifiStateEnableProcess.running = true; } function scan() { if (!ProgramCheckerService.nmcliAvailable || !Settings.data.network.wifiEnabled) return; if (scanning) { // Mark current scan results to be ignored and schedule a new scan Logger.d("Network", "Scan already in progress, will ignore results and rescan"); ignoreScanResults = true; scanPending = true; return; } scanning = true; lastError = ""; ignoreScanResults = false; // Get existing profiles first, then scan profileCheckProcess.running = true; Logger.d("Network", "Wi-Fi scan in progress..."); } // Returns true if we currently have any detectable Ethernet interfaces function hasEthernet() { return root.ethernetInterfaces && root.ethernetInterfaces.length > 0; } // Refresh only Ethernet state/details function refreshEthernet() { if (!ProgramCheckerService.nmcliAvailable) return; ethernetStateProcess.running = true; refreshActiveEthernetDetails(); } function connect(ssid, password = "") { if (!ProgramCheckerService.nmcliAvailable || connecting) return; connecting = true; connectingTo = ssid; lastError = ""; // Check if we have a saved connection if ((networks[ssid] && networks[ssid].existing) || cachedNetworks[ssid]) { connectProcess.mode = "saved"; connectProcess.ssid = ssid; connectProcess.password = ""; } else { connectProcess.mode = "new"; connectProcess.ssid = ssid; connectProcess.password = password; } connectProcess.running = true; } function disconnect(ssid) { if (!ProgramCheckerService.nmcliAvailable) return; disconnectingFrom = ssid; disconnectProcess.ssid = ssid; disconnectProcess.running = true; } function forget(ssid) { if (!ProgramCheckerService.nmcliAvailable) return; forgettingNetwork = ssid; // Remove from cache let known = cacheAdapter.knownNetworks; delete known[ssid]; cacheAdapter.knownNetworks = known; if (cacheAdapter.lastConnected === ssid) { cacheAdapter.lastConnected = ""; } saveCache(); // Remove from system forgetProcess.ssid = ssid; forgetProcess.running = true; } // Helper function to immediately update network status function updateNetworkStatus(ssid, connected) { let nets = networks; // Update all networks connected status for (let key in nets) { if (nets[key].connected && key !== ssid) { nets[key].connected = false; } } // Update the target network if it exists if (nets[ssid]) { nets[ssid].connected = connected; nets[ssid].existing = true; nets[ssid].cached = true; } else if (connected) { // Create a temporary entry if network doesn't exist yet nets[ssid] = { "ssid": ssid, "security": "--", "signal": 100, "connected": true, "existing": true, "cached": true }; } // Trigger property change notification networks = ({}); networks = nets; } // Helper functions function signalIcon(signal, isConnected) { if (isConnected === undefined) isConnected = false; if (isConnected && !root.internetConnectivity) return "world-off"; if (signal >= 80) return "wifi"; if (signal >= 50) return "wifi-2"; if (signal >= 20) return "wifi-1"; return "wifi-0"; } function isSecured(security) { return security && security !== "--" && security.trim() !== ""; } function getSignalStrengthLabel(signal) { switch (true) { case (signal >= 80): return I18n.tr("wifi.signal.excellent"); case (signal >= 50): return I18n.tr("wifi.signal.good"); case (signal >= 20): return I18n.tr("wifi.signal.fair"); default: return I18n.tr("wifi.signal.poor"); } } // Processes Process { id: ethernetStateProcess running: ProgramCheckerService.nmcliAvailable command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"] stdout: StdioCollector { onStreamFinished: { var connected = false; var devIf = ""; var lines = text.split("\n"); var ethList = []; for (var i = 0; i < lines.length; i++) { var parts = lines[i].split(":"); if (parts.length >= 3 && parts[1] === "ethernet") { var ifname = parts[0]; var state = parts[2]; var isConn = state === "connected"; ethList.push({ ifname: ifname, state: state, connected: isConn }); if (isConn && !connected) { connected = true; devIf = ifname; } } } // Sort interfaces: connected first, then by name ethList.sort(function (a, b) { if (a.connected !== b.connected) return a.connected ? -1 : 1; return a.ifname.localeCompare(b.ifname); }); root.ethernetInterfaces = ethList; if (root.ethernetConnected !== connected) { root.ethernetConnected = connected; Logger.d("Network", "Ethernet connected:", root.ethernetConnected); } if (connected) { if (root.activeEthernetIf !== devIf) { root.activeEthernetIf = devIf; // refresh details for the new interface root.activeEthernetDetailsTimestamp = 0; } root.refreshActiveEthernetDetails(); } else { // Preserve the selected interface; just clear details so UI shows a disconnected state root.activeEthernetDetails = ({}); root.activeEthernetDetailsTimestamp = Date.now(); } } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("Network", "ethernetState nmcli stderr:", text.trim()); } } } } // Discover connected Ethernet interface and fetch details Process { id: ethernetDeviceListProcess running: false command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"] stdout: StdioCollector { onStreamFinished: { let ifname = ""; const lines = text.split("\n"); const ethList = []; for (let i = 0; i < lines.length; i++) { const parts = lines[i].trim().split(":"); if (parts.length >= 3) { const dev = parts[0]; const type = parts[1]; const state = parts[2]; if (type === "ethernet" && state === "connected") { ifname = dev; } if (type === "ethernet") { ethList.push({ ifname: dev, state: state, connected: state === "connected" }); } } } ethList.sort(function (a, b) { if (a.connected !== b.connected) return a.connected ? -1 : 1; return a.ifname.localeCompare(b.ifname); }); root.ethernetInterfaces = ethList; if (ifname) { if (root.activeEthernetIf !== ifname) root.activeEthernetIf = ifname; ethernetDeviceShowProcess.ifname = ifname; ethernetDeviceShowProcess.running = true; } else { root.activeEthernetDetailsTimestamp = Date.now(); root.ethernetDetailsLoading = false; } } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("Network", "nmcli device list (eth) stderr:", text.trim()); } if (!root.activeEthernetIf) { root.activeEthernetDetailsTimestamp = Date.now(); root.ethernetDetailsLoading = false; } } } } // Fetch IPv4/Gateway/DNS and Connection Name for Ethernet iface Process { id: ethernetDeviceShowProcess property string ifname: "" running: false // Speed is resolved via ethtool fallback below to avoid stderr warnings command: ["nmcli", "-t", "-f", "GENERAL.CONNECTION,IP4.ADDRESS,IP4.GATEWAY,IP4.DNS", "device", "show", ifname] stdout: StdioCollector { onStreamFinished: { const details = root.activeEthernetDetails || ({}); let connName = ""; let ipv4 = ""; let gw4 = ""; let dnsServers = []; const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const idx = line.indexOf(":"); if (idx === -1) continue; const key = line.substring(0, idx); const val = line.substring(idx + 1); if (key === "GENERAL.CONNECTION") { connName = val; } else if (key.indexOf("IP4.ADDRESS") === 0) { ipv4 = val.split("/")[0]; } else if (key === "IP4.GATEWAY") { gw4 = val; } else if (key.indexOf("IP4.DNS") === 0) { if (val && dnsServers.indexOf(val) === -1) dnsServers.push(val); } } details.ifname = ethernetDeviceShowProcess.ifname; details.connectionName = connName; // No speed from nmcli: keep empty so ethtool fallback below fills it details.speed = details.speed && details.speed.length > 0 ? details.speed : ""; details.ipv4 = ipv4; details.gateway4 = gw4; details.dnsServers = dnsServers; details.dns = dnsServers.join(", "); root.activeEthernetDetails = details; // If speed missing, try sysfs first, then fallback to ethtool if (!details.speed || details.speed.length === 0) { ethernetSysfsSpeedProcess.ifname = ethernetDeviceShowProcess.ifname; ethernetSysfsSpeedProcess.running = true; } else { root.activeEthernetDetailsTimestamp = Date.now(); root.ethernetDetailsLoading = false; } } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("Network", "nmcli device show (eth) stderr:", text.trim()); } root.activeEthernetDetailsTimestamp = Date.now(); root.ethernetDetailsLoading = false; } } } // Try to read Ethernet speed from sysfs first: /sys/class/net//speed (numeric Mbit/s) Process { id: ethernetSysfsSpeedProcess property string ifname: "" running: false command: ["sh", "-c", "cat '/sys/class/net/" + ifname + "/speed' 2>/dev/null || true"] stdout: StdioCollector { onStreamFinished: { const details = root.activeEthernetDetails || ({}); let speedText = ""; const v = text.trim(); // Expect a number like 1000 const num = parseFloat(v); if (!isNaN(num) && num > 0) { details.speed = Math.round(num) + " Mbit/s"; details.speedMbit = num; root.activeEthernetDetails = details; root.activeEthernetDetailsTimestamp = Date.now(); root.ethernetDetailsLoading = false; } else { // Fallback to ethtool if sysfs unreadable or invalid ethernetEthtoolProcess.ifname = ethernetSysfsSpeedProcess.ifname; ethernetEthtoolProcess.running = true; } } } stderr: StdioCollector {} } // Optional: query Ethernet speed via ethtool as a fallback Process { id: ethernetEthtoolProcess property string ifname: "" running: false command: ["sh", "-c", "ethtool '" + ifname + "' 2>/dev/null || true"] stdout: StdioCollector { onStreamFinished: { const details = root.activeEthernetDetails || ({}); let speedText = ""; const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.toLowerCase().indexOf("speed:") === 0) { // Example: "Speed: 1000Mb/s" const v = line.substring(6).trim(); if (v) { // Normalize to "1000 Mbit/s" const normalized = v.replace(/mb\/?s/i, "Mbit/s").replace(/\s+/g, " "); speedText = normalized; } break; } } if (speedText && speedText.length > 0) { details.speed = speedText; // Try to derive numeric value const m = speedText.match(/([0-9]+(?:\.[0-9]+)?)\s*Mbit\/s/i); if (m) details.speedMbit = parseFloat(m[1]); root.activeEthernetDetails = details; } root.activeEthernetDetailsTimestamp = Date.now(); root.ethernetDetailsLoading = false; } } stderr: StdioCollector {} } // Discover connected Wi‑Fi interface Process { id: wifiDeviceListProcess running: false command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"] stdout: StdioCollector { onStreamFinished: { let ifname = ""; const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const parts = lines[i].trim().split(":"); if (parts.length >= 3) { const dev = parts[0]; const type = parts[1]; const state = parts[2]; if (type === "wifi" && state === "connected") { ifname = dev; break; } } } root.activeWifiIf = ifname; if (ifname) { wifiDeviceShowProcess.ifname = ifname; wifiDeviceShowProcess.running = true; } else { // Nothing to fetch root.activeWifiDetailsTimestamp = Date.now(); root.detailsLoading = false; } } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("Network", "nmcli device list stderr:", text.trim()); } // Fail-safe to avoid spinner if (!root.activeWifiIf) { root.activeWifiDetailsTimestamp = Date.now(); root.detailsLoading = false; } } } } // Fetch IPv4 and gateway for the interface Process { id: wifiDeviceShowProcess property string ifname: "" running: false command: ["nmcli", "-t", "-f", "IP4.ADDRESS,IP4.GATEWAY,IP4.DNS", "device", "show", ifname] stdout: StdioCollector { onStreamFinished: { const details = root.activeWifiDetails || ({}); let ipv4 = ""; let gw4 = ""; let dnsServers = []; const lines = text.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const idx = line.indexOf(":"); if (idx === -1) continue; const key = line.substring(0, idx); const val = line.substring(idx + 1); if (key.indexOf("IP4.ADDRESS") === 0) { ipv4 = val.split("/")[0]; } else if (key === "IP4.GATEWAY") { gw4 = val; } else if (key.indexOf("IP4.DNS") === 0) { if (val && dnsServers.indexOf(val) === -1) { dnsServers.push(val); } } } details.ipv4 = ipv4; details.gateway4 = gw4; details.dnsServers = dnsServers; details.dns = dnsServers.join(", "); root.activeWifiDetails = details; // Try to get link rate (best effort) wifiIwLinkProcess.ifname = wifiDeviceShowProcess.ifname; wifiIwLinkProcess.running = true; } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("Network", "nmcli device show stderr:", text.trim()); } // Still proceed to finalize details to avoid UI waiting forever root.activeWifiDetailsTimestamp = Date.now(); } } } // Optional: query Wi‑Fi bitrate via iw if available Process { id: wifiIwLinkProcess property string ifname: "" running: false command: ["sh", "-c", "iw dev '" + ifname + "' link 2>/dev/null || true"] stdout: StdioCollector { onStreamFinished: { const details = root.activeWifiDetails || ({}); let rate = ""; let freq = ""; const lines = text.split("\n"); for (var k = 0; k < lines.length; k++) { var line2 = lines[k].trim(); var low = line2.toLowerCase(); if (low.indexOf("tx bitrate:") === 0) { rate = line2.substring(11).trim(); } else if (low.indexOf("freq:") === 0) { freq = line2.substring(5).trim(); } } // Determine band from frequency // https://en.wikipedia.org/wiki/List_of_WLAN_channels let band = ""; if (freq) { const f = +freq; if (f) { switch (true) { // https://en.wikipedia.org/wiki/List_of_WLAN_channels#6_GHz_(802.11ax_and_802.11be) case (f >= 5925 && f < 7125): band = "6 GHz"; break; // https://en.wikipedia.org/wiki/List_of_WLAN_channels#5_GHz_(802.11a/h/n/ac/ax/be) case (f >= 5150 && f < 5925): band = "5 GHz"; break; // https://en.wikipedia.org/wiki/List_of_WLAN_channels#2.4_GHz_(802.11b/g/n/ax/be) case (f >= 2400 && f < 2500): band = "2.4 GHz"; break; default: band = `${f} MHz`; } } } // Shorten verbose bitrate strings like: "360.0 MBit/s VHT-MCS 8 40MHz short GI" let rateShort = ""; if (rate) { var parts = rate.trim().split(" "); // compact consecutive spaces var compact = []; for (var i = 0; i < parts.length; i++) { var p = parts[i]; if (p && p.length > 0) compact.push(p); } // Find a token that represents Mbit/s and use the previous number var unitIdx = -1; for (var j = 0; j < compact.length; j++) { var token = compact[j].toLowerCase(); if (token === "mbit/s" || token === "mb/s" || token === "mbits/s") { unitIdx = j; break; } } if (unitIdx > 0) { var num = compact[unitIdx - 1]; // Basic numeric check var parsed = parseFloat(num); if (!isNaN(parsed)) { rateShort = parsed + " Mbit/s"; } } if (!rateShort) { // Fallback to first two tokens rateShort = compact.slice(0, 2).join(" "); } } details.rate = rate; details.rateShort = rateShort; details.band = band; root.activeWifiDetails = details; root.activeWifiDetailsTimestamp = Date.now(); root.detailsLoading = false; } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("Network", "iw link stderr:", text.trim()); } root.activeWifiDetailsTimestamp = Date.now(); root.detailsLoading = false; } } } // Only check the state of the actual interface // and update our setting to be in sync. Process { id: wifiStateProcess running: false command: ["nmcli", "radio", "wifi"] stdout: StdioCollector { onStreamFinished: { const enabled = text.trim() === "enabled"; Logger.d("Network", "Wi-Fi adapter was detect as enabled:", enabled); if (Settings.data.network.wifiEnabled !== enabled) { Settings.data.network.wifiEnabled = enabled; } } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("Network", "Wi-Fi state query stderr:", text.trim()); } } } } // Process to enable/disable the Wi-Fi interface Process { id: wifiStateEnableProcess running: false command: ["nmcli", "radio", "wifi", Settings.data.network.wifiEnabled ? "on" : "off"] stdout: StdioCollector { onStreamFinished: { Logger.i("Network", "Wi-Fi state change command executed"); // Re-check the state to ensure it's in sync syncWifiState(); } } stderr: StdioCollector { onStreamFinished: { if (text.trim()) { Logger.w("Network", "Error changing Wi-Fi state: " + text); } } } } // Process to check the internet connectivity of the connected network Process { id: connectivityCheckProcess running: false command: ["nmcli", "networking", "connectivity", "check"] property int failedChecks: 0 stdout: StdioCollector { onStreamFinished: { const result = text.trim(); if (!result) { return; } if (result === "full" || result === "none" || result === "unknown") { if (connectivityCheckProcess.failedChecks !== 0) { connectivityCheckProcess.failedChecks = 0; } if (result !== root.networkConnectivity) { root.networkConnectivity = result; root.scan(); } return; } if ((result === "limited" || result === "portal") && result !== root.networkConnectivity) { connectivityCheckProcess.failedChecks++; if (connectivityCheckProcess.failedChecks === 3) { root.networkConnectivity = result; pingCheckProcess.running = true; } } } } stderr: StdioCollector { onStreamFinished: { if (text.trim()) { Logger.w("Network", "Connectivity check error: " + text); } } } } Process { id: pingCheckProcess command: ["sh", "-c", "ping -c1 -W2 ping.archlinux.org >/dev/null 2>&1 || " + "ping -c1 -W2 1.1.1.1 >/dev/null 2>&1 || " + "curl -fsI --max-time 5 https://cloudflare.com/cdn-cgi/trace >/dev/null 2>&1"] onExited: function (exitCode, exitStatus) { if (exitCode === 0) { connectivityCheckProcess.failedChecks = 0; } else { root.internetConnectivity = false; Logger.i("Network", "No internet connectivity"); ToastService.showWarning(root.cachedLastConnected, I18n.tr("toast.internet-limited")); connectivityCheckProcess.failedChecks = 0; } root.scan(); } } // Helper process to get existing profiles Process { id: profileCheckProcess running: false command: ["nmcli", "-t", "-f", "NAME", "connection", "show"] stdout: StdioCollector { onStreamFinished: { if (root.ignoreScanResults) { Logger.d("Network", "Ignoring profile check results (new scan requested)"); root.scanning = false; // Check if we need to start a new scan if (root.scanPending) { root.scanPending = false; delayedScanTimer.interval = 100; delayedScanTimer.restart(); } return; } var profiles = {}; var lines = text.split("\n"); for (var i = 0; i < lines.length; i++) { var l = lines[i]; if (l && l.trim()) { profiles[l.trim()] = true; } } scanProcess.existingProfiles = profiles; scanProcess.running = true; } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("Network", "Profile check stderr:", text.trim()); // Fail safe - only restart scan on actual error if (root.scanning) { root.scanning = false; delayedScanTimer.interval = 5000; delayedScanTimer.restart(); } } } } } Process { id: scanProcess running: false command: ["nmcli", "-t", "-f", "SSID,SECURITY,SIGNAL,IN-USE", "device", "wifi", "list", "--rescan", "yes"] property var existingProfiles: ({}) stdout: StdioCollector { onStreamFinished: { if (root.ignoreScanResults) { Logger.d("Network", "Ignoring scan results (new scan requested)"); root.scanning = false; // Check if we need to start a new scan if (root.scanPending) { root.scanPending = false; delayedScanTimer.interval = 100; delayedScanTimer.restart(); } return; } // Process the scan results as before... const lines = text.split("\n"); const networksMap = {}; for (var i = 0; i < lines.length; ++i) { const line = lines[i].trim(); if (!line) continue; // Parse from the end to handle SSIDs with colons // Format is SSID:SECURITY:SIGNAL:IN-USE // We know the last 3 fields, so everything else is SSID const lastColonIdx = line.lastIndexOf(":"); if (lastColonIdx === -1) { Logger.w("Network", "Malformed nmcli output line:", line); continue; } const inUse = line.substring(lastColonIdx + 1); const remainingLine = line.substring(0, lastColonIdx); const secondLastColonIdx = remainingLine.lastIndexOf(":"); if (secondLastColonIdx === -1) { Logger.w("Network", "Malformed nmcli output line:", line); continue; } const signal = remainingLine.substring(secondLastColonIdx + 1); const remainingLine2 = remainingLine.substring(0, secondLastColonIdx); const thirdLastColonIdx = remainingLine2.lastIndexOf(":"); if (thirdLastColonIdx === -1) { Logger.w("Network", "Malformed nmcli output line:", line); continue; } let security = remainingLine2.substring(thirdLastColonIdx + 1); // This change will add a slash where mixed security protocols are used. if (security) { security = security.replace("WPA2 WPA3", "WPA2/WPA3").replace("WPA1 WPA2", "WPA1/WPA2"); } const ssid = remainingLine2.substring(0, thirdLastColonIdx); if (ssid) { const signalInt = parseInt(signal) || 0; const connected = inUse === "*"; // Track connected network in cache if (connected && cacheAdapter.lastConnected !== ssid) { cacheAdapter.lastConnected = ssid; saveCache(); } if (!networksMap[ssid]) { networksMap[ssid] = { "ssid": ssid, "security": security || "--", "signal": signalInt, "connected": connected, "existing": ssid in scanProcess.existingProfiles, "cached": ssid in cacheAdapter.knownNetworks }; } else { // Keep the best signal for duplicate SSIDs const existingNet = networksMap[ssid]; if (connected) { existingNet.connected = true; } if (signalInt > existingNet.signal) { existingNet.signal = signalInt; existingNet.security = security || "--"; } } } } // Logging const oldSSIDs = Object.keys(root.networks); const newSSIDs = Object.keys(networksMap); const newNetworks = newSSIDs.filter(function (ssid) { return oldSSIDs.indexOf(ssid) === -1; }); const lostNetworks = oldSSIDs.filter(function (ssid) { return newSSIDs.indexOf(ssid) === -1; }); if (newNetworks.length > 0 || lostNetworks.length > 0) { if (newNetworks.length > 0) { Logger.d("Network", "New Wi-Fi SSID discovered:", newNetworks.join(", ")); } if (lostNetworks.length > 0) { Logger.d("Network", "Wi-Fi SSID disappeared:", lostNetworks.join(", ")); } Logger.d("Network", "Total Wi-Fi SSIDs:", Object.keys(networksMap).length); } Logger.d("Network", "Wi-Fi scan completed"); root.networks = networksMap; root.scanning = false; // Preload active Wi‑Fi details so Info panel shows instantly when opened // This is lightweight and guarded by detailsLoading + TTL. var hasConnected = false; for (var ssid in networksMap) { if (networksMap.hasOwnProperty(ssid)) { var net = networksMap[ssid]; if (net && net.connected) { hasConnected = true; break; } } } if (hasConnected) { root.refreshActiveWifiDetails(); } // Check if we need to start a new scan if (root.scanPending) { root.scanPending = false; delayedScanTimer.interval = 100; delayedScanTimer.restart(); } } } stderr: StdioCollector { onStreamFinished: { root.scanning = false; if (text.trim()) { Logger.w("Network", "Scan error: " + text); // If scan fails, retry delayedScanTimer.interval = 5000; delayedScanTimer.restart(); } } } } Process { id: connectProcess property string mode: "new" property string ssid: "" property string password: "" running: false command: { if (mode === "saved") { return ["nmcli", "connection", "up", "id", ssid]; } else { var cmd = ["nmcli", "device", "wifi", "connect", ssid]; if (password) { cmd.push("password", password); } return cmd; } } environment: ({ "LC_ALL": "C" }) stdout: StdioCollector { onStreamFinished: { // Check if the output actually indicates success // nmcli outputs "Device '...' successfully activated" or "Connection successfully activated" // on success. Empty output or other messages indicate failure. const output = text.trim(); if (!output || (output.indexOf("successfully activated") === -1 && output.indexOf("Connection successfully") === -1)) { // No success message - likely an error occurred // Don't update anything, let stderr handler deal with it return; } // Success - update cache let known = cacheAdapter.knownNetworks; known[connectProcess.ssid] = { "profileName": connectProcess.ssid, "lastConnected": Date.now() }; cacheAdapter.knownNetworks = known; cacheAdapter.lastConnected = connectProcess.ssid; saveCache(); // Immediately update the UI before scanning root.updateNetworkStatus(connectProcess.ssid, true); // Preload details immediately so Info panel has data instantly root.refreshActiveWifiDetails(); root.connecting = false; root.connectingTo = ""; Logger.i("Network", "Connected to network: '" + connectProcess.ssid + "'"); ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.connected", { "ssid": connectProcess.ssid }), "wifi"); // Still do a scan to get accurate signal and security info delayedScanTimer.interval = 5000; delayedScanTimer.restart(); } } stderr: StdioCollector { onStreamFinished: { root.connecting = false; root.connectingTo = ""; if (text.trim()) { // Parse common errors if (text.indexOf("Secrets were required") !== -1 || text.indexOf("no secrets provided") !== -1) { root.lastError = I18n.tr("toast.wifi.incorrect-password"); forget(connectProcess.ssid); } else if (text.indexOf("No network with SSID") !== -1) { root.lastError = I18n.tr("toast.wifi.network-not-found"); } else if (text.indexOf("Timeout") !== -1) { root.lastError = I18n.tr("toast.wifi.connection-timeout"); } else { // Generic fallback root.lastError = I18n.tr("toast.wifi.connection-failed"); } Logger.w("Network", "Connect error: " + text); // Notify user about the failure ToastService.showWarning(I18n.tr("wifi.panel.title"), root.lastError || I18n.tr("toast.wifi.connection-failed")); } } } } Process { id: disconnectProcess property string ssid: "" running: false command: ["nmcli", "connection", "down", "id", ssid] stdout: StdioCollector { onStreamFinished: { Logger.i("Network", "Disconnected from network: '" + disconnectProcess.ssid + "'"); ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.disconnected", { "ssid": disconnectProcess.ssid }), "wifi-off"); // Immediately update UI on successful disconnect root.updateNetworkStatus(disconnectProcess.ssid, false); root.disconnectingFrom = ""; // Do a scan to refresh the list delayedScanTimer.interval = 1000; delayedScanTimer.restart(); } } stderr: StdioCollector { onStreamFinished: { root.disconnectingFrom = ""; if (text.trim()) { Logger.w("Network", "Disconnect error: " + text); } // Still trigger a scan even on error delayedScanTimer.interval = 5000; delayedScanTimer.restart(); } } } Process { id: forgetProcess property string ssid: "" running: false // Try multiple common profile name patterns command: { var script = ""; script += "ssid=\"$1\"\n"; script += "deleted=false\n\n"; script += "# Try exact SSID match first\n"; script += "if nmcli connection delete id \"$ssid\" 2>/dev/null; then\n"; script += " echo \"Deleted profile: $ssid\"\n"; script += " deleted=true\n"; script += "fi\n\n"; script += "# Try \"Auto $ssid\" pattern\n"; script += "if nmcli connection delete id \"Auto $ssid\" 2>/dev/null; then\n"; script += " echo \"Deleted profile: Auto $ssid\"\n"; script += " deleted=true\n"; script += "fi\n\n"; script += "# Try \"$ssid 1\", \"$ssid 2\", etc. patterns\n"; script += "for i in 1 2 3; do\n"; script += " if nmcli connection delete id \"$ssid $i\" 2>/dev/null; then\n"; script += " echo \"Deleted profile: $ssid $i\"\n"; script += " deleted=true\n"; script += " fi\n"; script += "done\n\n"; script += "if [ \"$deleted\" = \"false\" ]; then\n"; script += " echo \"No profiles found for SSID: $ssid\"\n"; script += "fi\n"; return ["sh", "-c", script, "--", ssid]; } stdout: StdioCollector { onStreamFinished: { Logger.i("Network", "Forget network: \"" + forgetProcess.ssid + "\""); Logger.d("Network", text.trim().replace(/[\r\n]/g, " ")); // Update both cached and existing status immediately let nets = root.networks; if (nets[forgetProcess.ssid]) { nets[forgetProcess.ssid].cached = false; nets[forgetProcess.ssid].existing = false; // Trigger property change root.networks = ({}); root.networks = nets; } root.forgettingNetwork = ""; // Scan to verify the profile is gone delayedScanTimer.interval = 5000; delayedScanTimer.restart(); } } stderr: StdioCollector { onStreamFinished: { root.forgettingNetwork = ""; if (text.trim() && text.indexOf("No profiles found") === -1) { Logger.w("Network", "Forget error: " + text); } // Still Trigger a scan even on error delayedScanTimer.interval = 5000; delayedScanTimer.restart(); } } } }