Keep the Wi-Fi icon steady on OWE transition-mode networks (#14133)

* Keep the Wi-Fi icon steady on OWE transition-mode networks

An OWE transition-mode network pairs an open SSID with a hidden "_owetm_"
twin on the same BSSID. Between scans NetworkManager reports the in-use
access point under the hidden SSID and drops the active profile from the
device's AvailableConnections, which is the only place Quickshell builds
known networks from. No listed network is then connected, so the bar fell
back to the disconnected icon until the next scan brought the open SSID
back, flickering on and off while the link stayed up.

Fall back to the Wi-Fi device's own connected state when no listed network
is connected, and read the in-use access point's strength from nmcli
(without a rescan) while the connected network has none of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Discard stale in-use AP reads and key Wi-Fi checks on the device

Bump a generation whenever the cached in-use access point strength stops
describing the link (leaving Wi-Fi or a device change), and drop any nmcli
read started before it, re-reading immediately instead of a full interval
later.

Key Wi-Fi connectivity checks on the device rather than the SSID, so the
listed network coming and going with each scan on an OWE transition-mode
network no longer schedules a check. A real network switch still passes
through "disconnected".

Add a QML fixture that runs the panel against a mocked device through the
scan churn, a mid-read disconnect, and a final disconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-10-03 20:05:32 -04:00
1 parent 8e02fc84f5
commit 18328559b9
5 files changed
+289 -4

No files matched your search

+24
View File
@@ -14,6 +14,28 @@ function wifiIconFor(strength) {
return icons[index]
}
// The connected network's own strength comes from the access points
// Quickshell grouped under its SSID. When it has none (an OWE transition-mode
// network between scans files the in-use access point under its hidden
// "_owetm_" twin), fall back to the strength NetworkManager reports for the
// access point in use.
function connectedSignalStrength(networkStrength, activeApSignal) {
var strength = Math.round((networkStrength || 0) * 100)
if (strength > 0) return strength
return activeApSignal > 0 ? activeApSignal : strength
}
// Reads `nmcli -t -f IN-USE,SIGNAL device wifi list` output, where the in-use
// access point is the row marked "*".
function parseActiveApSignal(raw) {
var lines = String(raw || "").split(/\r?\n/)
for (var i = 0; i < lines.length; i++) {
var match = /^\*:(\d+)$/.exec(lines[i].trim())
if (match) return parseInt(match[1], 10)
}
return -1
}
// A known plain-HTTP endpoint lets the network redirect the browser to its
// login page. Never execute or automatically open an untrusted Location header.
var captivePortalUrl = "http://ping.archlinux.org/nm-check.txt"
@@ -368,6 +390,8 @@ if (typeof module !== "undefined") {
parseNetworkStatus: parseNetworkStatus,
wifiIconFor: wifiIconFor,
connectionIcon: connectionIcon,
connectedSignalStrength: connectedSignalStrength,
parseActiveApSignal: parseActiveApSignal,
connectivityState: connectivityState,
captivePortalUrl: captivePortalUrl,
formatHeaderSpeed: formatHeaderSpeed,
+69 -4
View File
@@ -388,6 +388,7 @@ Panel {
onWifiDeviceChanged: {
setScannerEnabled(true)
syncWifiNetworks()
resetActiveApSignal()
}
onWifiNetworkObjectsChanged: syncWifiNetworks()
@@ -441,11 +442,52 @@ Panel {
readonly property string kind: {
if (wiredDevice && wiredDevice.connected) return "ethernet"
if (connectedWifiNetwork) return "wifi"
// NetworkManager can leave the active profile out of the device's
// AvailableConnections, and Quickshell only builds known networks from
// that list. An OWE transition-mode network does this between scans: the
// in-use access point carries the hidden "_owetm_" SSID, so the profile
// for the open SSID looks out of range even while it is connected. Trust
// the device's own state then instead of flickering to disconnected.
if (wifiDevice && wifiDevice.connected) return "wifi"
return "disconnected"
}
readonly property int signalStrength: connectedWifiNetwork
? Math.round((connectedWifiNetwork.signalStrength || 0) * 100)
readonly property int signalStrength: kind === "wifi"
? Model.connectedSignalStrength(connectedWifiNetwork ? connectedWifiNetwork.signalStrength : 0, activeApSignal)
: -1
// Filled by activeApSignalPoll from nmcli while the connected network has no
// strength of its own to report.
property int activeApSignal: -1
readonly property bool needsActiveApSignal: kind === "wifi"
&& !(connectedWifiNetwork && connectedWifiNetwork.signalStrength > 0)
// Bumped whenever the cached reading stops describing the current link, so
// an nmcli read still in flight from before cannot write a stale strength.
property int activeApSignalGeneration: 0
property int activeApSignalRequest: -1
onKindChanged: if (kind !== "wifi") resetActiveApSignal()
// Switching Wi-Fi networks always passes through a non-"wifi" kind (both the
// device and the old network drop out of the connected state while the new
// profile activates), so this also covers a change of network.
function resetActiveApSignal() {
activeApSignal = -1
activeApSignalGeneration++
}
function pollActiveApSignal() {
if (activeApSignalProc.running) return
activeApSignalRequest = activeApSignalGeneration
activeApSignalProc.running = true
}
function finishActiveApSignal(raw) {
if (activeApSignalRequest !== activeApSignalGeneration) {
// The link changed mid-read and the restarted poll skipped while this
// one was running; read again now instead of a full interval later.
if (needsActiveApSignal) activeApSignalPoll.restart()
return
}
activeApSignal = Model.parseActiveApSignal(raw)
}
function copyToClipboard(value) {
if (!value || !root.bar) return
@@ -464,8 +506,11 @@ Panel {
readonly property bool hasCaptivePortal: connectivity === "portal"
readonly property bool restricted: hasCaptivePortal || connectivity === "limited"
readonly property string icon: Model.connectionIcon(kind, signalStrength, connectivity)
readonly property string connectionKey: kind === "wifi" && wifiDevice && connectedWifiNetwork
? kind + ":" + wifiDevice.name + ":" + connectedWifiNetwork.name
// Keyed on the device rather than the SSID: on an OWE transition-mode
// network the listed network comes and goes with every scan while the link
// stays up, and a real network switch already passes through "disconnected".
readonly property string connectionKey: kind === "wifi" && wifiDevice
? kind + ":" + wifiDevice.name
: (kind === "ethernet" && wiredDevice ? kind + ":" + wiredDevice.name : "")
onConnectionKeyChanged: Qt.callLater(checkConnectivity)
@@ -871,6 +916,26 @@ Panel {
}
}
// Reads the in-use access point's strength without triggering a scan, for
// the connected network that has none of its own (see signalStrength).
Process {
id: activeApSignalProc
command: ["nmcli", "-t", "-f", "IN-USE,SIGNAL", "device", "wifi", "list", "ifname", root.wifiDevice ? root.wifiDevice.name : "", "--rescan", "no"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.finishActiveApSignal(text)
}
}
Timer {
id: activeApSignalPoll
interval: 5000
repeat: true
triggeredOnStart: true
running: root.needsActiveApSignal
onTriggered: root.pollActiveApSignal()
}
Timer {
id: scanRestart
interval: 100