Files
noctalia-shell/Services/Networking/NetworkService.qml
T
2026-01-22 02:26:22 +01:00

1327 lines
42 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 WiFi 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("common.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("common.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 WiFi 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/<if>/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 WiFi 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 WiFi 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) {
if (result === "full") {
root.internetConnectivity = true;
}
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 WiFi 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();
}
}
}
}