pragma Singleton // Network and Bluetooth state for the settings page. // // The hard parts -- scanning, joining, pairing -- already work in the quick // settings panel through Quickshell.Networking and Quickshell.Bluetooth, which // speak to NetworkManager and BlueZ over DBus. Nothing here shells out to nmcli // or bluetoothctl, and nothing should: the founding requirement for this // desktop was never having to drop to a terminal to join a network. // // This exists so the page does not have to reach into those modules for the // same derived values the panel already computes, and so scanning is driven by // whether the page is actually on screen. Scanning while nobody is looking is // radio time and battery spent on a list that is not being read. import Quickshell import Quickshell.Networking import Quickshell.Bluetooth import QtQuick Singleton { id: root // Set by the settings page while it is visible. One of several holds on // the radios rather than the only one -- see the scan holds below. property bool active: false // ── Scan holds ─────────────────────────────────────────────────────────── // // Two surfaces list the same radios: this page and the quick-settings // panel. Both used to write scannerEnabled and adapter.discovering from // their own visibility flag, and the last writer won -- so closing the // settings page turned scanning off underneath an open quick-settings // panel, which then sat on "Searching…" forever with nothing searching. // // So the radios are held rather than switched. Each surface acquires a // named hold while it is on screen and releases it when it goes away, and // scanning runs while ANY hold is outstanding. Names rather than a counter: // these holders are QML items that can be destroyed with a release already // in flight, and a counter that drifts either leaves the radio on forever // or turns it off under somebody. Acquiring a name twice is acquiring it // once. property var wifiScanHolders: [] property var discoveryHolders: [] readonly property bool wifiScanWanted: root.wifiScanHolders.length > 0 readonly property bool discoveryWanted: root.discoveryHolders.length > 0 // Whether it was this service that started BlueZ discovering. Something // else on the machine may already have been -- bluetoothctl, another // desktop component -- and stopping a scan we did not start is not ours to // do. property bool discoveryOwned: false function acquireWifiScan(holder: string): void { if (holder === "" || root.wifiScanHolders.indexOf(holder) >= 0) return; root.wifiScanHolders = root.wifiScanHolders.concat([holder]); root.syncScanners(); } function releaseWifiScan(holder: string): void { if (root.wifiScanHolders.indexOf(holder) < 0) return; root.wifiScanHolders = root.wifiScanHolders.filter(name => name !== holder); root.syncScanners(); } function acquireDiscovery(holder: string): void { if (holder === "" || root.discoveryHolders.indexOf(holder) >= 0) return; root.discoveryHolders = root.discoveryHolders.concat([holder]); root.syncScanners(); } function releaseDiscovery(holder: string): void { if (root.discoveryHolders.indexOf(holder) < 0) return; root.discoveryHolders = root.discoveryHolders.filter(name => name !== holder); root.syncScanners(); } readonly property var wifiDevice: { for (const device of Networking.devices.values) { if (device.type === DeviceType.Wifi) return device; } return null; } readonly property var wiredDevice: { let fallback = null; for (const device of Networking.devices.values) { if (device.type !== DeviceType.Wired) continue; if (device.connected) return device; if (!fallback) fallback = device; } return fallback; } // Whether the wired connection is on, as a person means it: carrying // traffic, and set to come back by itself. readonly property bool wiredOn: root.wiredDevice !== null && root.wiredDevice.connected // Deliberately NOT gated on hasLink. That property reads false while the // device is merely disconnected -- NetworkManager still reports the carrier // as on -- so using it to decide whether the switch works built a trap // door: turning Ethernet off made the switch disable itself, claim "no // cable", and leave no way to turn it back on. A wired device that exists // can always be asked to come up; if there is genuinely no cable, the // attempt fails and says so, which is the honest failure. readonly property bool wiredAvailable: root.wiredDevice !== null // Turning wired networking off means both halves. Disconnecting alone lasts // about a second: NetworkManager sees a managed device with autoconnect set // and immediately brings it back, so the switch would flip itself on again // and read as broken. function setWired(enabled: bool): void { const device = root.wiredDevice; if (!device) return; device.autoconnect = enabled; if (enabled) { // With autoconnect restored NetworkManager will usually bring it up // on its own; asking directly makes it immediate rather than // whenever the daemon next looks. if (device.network) device.network.connect(); } else { device.disconnect(); } } readonly property var adapter: Bluetooth.defaultAdapter readonly property bool wifiEnabled: Networking.wifiEnabled readonly property bool wifiAvailable: Networking.wifiHardwareEnabled // The radio switches, as functions rather than as writes a page makes for // itself. Both are one-line assignments to the native modules, which is // exactly the point: a page that reached past this service to set // Networking.wifiEnabled directly would be one page's worth of the rule // this file exists to keep -- and the next such write, needing a retry or a // guard, would have nowhere to live but the page. // // Native both ways. Nothing here shells out to nmcli or rfkill; airplane // mode, which genuinely needs rfkill, lives in NetworkTools.qml instead. function setWifiEnabled(enabled: bool): void { if (Networking.wifiEnabled !== enabled) Networking.wifiEnabled = enabled; } function setBluetoothEnabled(enabled: bool): void { const device = root.adapter; if (!device || device.enabled === enabled) return; device.enabled = enabled; } readonly property bool bluetoothEnabled: root.adapter?.enabled ?? false readonly property bool bluetoothAvailable: !!root.adapter // Current network, then saved, then by signal -- the order GNOME uses, // which is the order you actually look for things in. readonly property var networks: { if (!root.wifiDevice || !root.wifiDevice.networks) return []; const list = root.wifiDevice.networks.values.slice(); list.sort((a, b) => { if (a.connected !== b.connected) return a.connected ? -1 : 1; if (a.known !== b.known) return a.known ? -1 : 1; return b.signalStrength - a.signalStrength; }); return list; } readonly property var savedNetworks: root.networks.filter(network => network.known) readonly property var bluetoothDevices: { if (!Bluetooth.devices) return []; const list = Bluetooth.devices.values.slice(); list.sort((a, b) => { if (a.connected !== b.connected) return a.connected ? -1 : 1; if (a.paired !== b.paired) return a.paired ? -1 : 1; return String(a.name || "").localeCompare(String(b.name || "")); }); return list; } readonly property var activeNetwork: root.networks.find(network => network.connected) ?? null function isSecured(network: var): bool { return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe && network.security !== WifiSecurityType.Unknown; } // Member names come from the installed plugin's own qmltypes -- // Quickshell/Networking/quickshell-network.qmltypes, whose WifiSecurityType // is exactly: Wpa3SuiteB192, Sae, Wpa2Eap, Wpa2Psk, WpaEap, WpaPsk, // StaticWep, DynamicWep, Leap, Owe, Open, Unknown -- rather than the // GNOME-style names this switch used to test (Wep, Wpa, Wpa2, Wpa3, // Enterprise). None of those five exist, and a `case` against an undefined // member simply never matches, so every secured network read "Secured" and // "Enterprise" was unreachable. The enterprise join form keys off exactly // that string, so the whole 802.1X flow was dead. // // The open cases are named here rather than delegated to isSecured(), // which treats Unknown as unsecured. Calling a network whose security // nobody could read "Open" is a claim this cannot back up; Unknown falls // through to "Secured" instead. isSecured() keeps its own meaning -- "does // joining this need a passphrase" -- and is unchanged. // // Owe is Enhanced Open: encrypted, but joined without a passphrase, so to // someone picking a network it reads as open. function securityLabel(network: var): string { switch (network.security) { case WifiSecurityType.Open: return "Open"; case WifiSecurityType.Owe: return "Open"; case WifiSecurityType.StaticWep: return "WEP"; case WifiSecurityType.WpaPsk: return "WPA"; case WifiSecurityType.Wpa2Psk: return "WPA2"; case WifiSecurityType.Sae: return "WPA3"; case WifiSecurityType.WpaEap: return "Enterprise"; case WifiSecurityType.Wpa2Eap: return "Enterprise"; case WifiSecurityType.Wpa3SuiteB192: return "Enterprise"; case WifiSecurityType.DynamicWep: return "Enterprise"; case WifiSecurityType.Leap: return "Enterprise"; } return "Secured"; } // Four bars is what people read signal as, so bucket rather than showing a // percentage that changes every scan and means nothing to anyone. // // signalStrength is 0.0-1.0, NOT a percentage. Treating it as 0-100 puts // every network including the connected one in the bottom bucket, which is // exactly as useless as showing nothing. Thresholds match the icon buckets // in modules/quicksettings/WifiList.qml so the two never disagree. function signalLabel(strength: real): string { if (strength >= 0.8) return "Excellent"; if (strength >= 0.55) return "Good"; if (strength >= 0.3) return "Fair"; if (strength > 0.05) return "Weak"; return "No signal"; } // NoSecrets, not "Authentication" -- the qmltypes members are NoSecrets, // Unknown, WifiAuthTimeout, WifiClientDisconnected, WifiClientFailed, // WifiNetworkLost. A case against an undefined member never matches, so a // wrong password used to fall through to the generic text. function connectionFailureText(reason: var): string { switch (reason) { case ConnectionFailReason.WifiAuthTimeout: case ConnectionFailReason.NoSecrets: return "Wrong password"; case ConnectionFailReason.WifiNetworkLost: return "Network out of range"; } return "Could not connect"; } // Scanning follows the outstanding holds, not any one surface's visibility. // NetworkManager keeps scanning as long as it is asked to, and Bluetooth // discovery is worse -- it holds the radio. // // Computed from state and applied idempotently, so calling this after every // acquire, release and device change is free: it writes only when the radio // is not already where the holds say it should be. function syncScanners(): void { if (root.wifiDevice) root.wifiDevice.scannerEnabled = root.wifiScanWanted && root.wifiEnabled; if (!root.adapter || !root.adapter.enabled) return; const shouldDiscover = root.discoveryWanted; if (shouldDiscover && !root.adapter.discovering) { root.adapter.discovering = true; root.discoveryOwned = true; } else if (!shouldDiscover && root.discoveryOwned && root.adapter.discovering) { root.adapter.discovering = false; root.discoveryOwned = false; } } // The settings page's own hold, expressed through the flag it already sets. onActiveChanged: { if (root.active) { root.acquireWifiScan("settings"); root.acquireDiscovery("settings"); } else { root.releaseWifiScan("settings"); root.releaseDiscovery("settings"); } } onWifiDeviceChanged: root.syncScanners() onWifiEnabledChanged: root.syncScanners() onAdapterChanged: root.syncScanners() // adapter.enabled has no property on root to bind onXChanged to, so it // needs its own Connections -- the Bluetooth equivalent of onWifiEnabledChanged. Connections { target: root.adapter function onEnabledChanged(): void { root.syncScanners(); } } }