Files
Panama/config/dot/quickshell/services/Connectivity.qml
T

316 lines
14 KiB
QML

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(); }
}
}