316 lines
14 KiB
QML
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(); }
|
|
}
|
|
}
|