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:
1 parent
8e02fc84f5
commit
18328559b9
5 files changed
+289
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import "mocks"
|
||||
import "network" as Network
|
||||
import "network/Model.js" as Model
|
||||
|
||||
// Drives the bar state through an OWE transition-mode scan cycle: the
|
||||
// connected network drops out of the device's list between scans while the
|
||||
// device itself stays connected. The runner stubs nmcli to answer, in order,
|
||||
// 57, then a slow stale 11, then 33.
|
||||
ShellRoot {
|
||||
id: test
|
||||
property bool failed: false
|
||||
property int checksBefore: 0
|
||||
property var listed: ({ values: [NetworkMock.network] })
|
||||
function check(ok, message) {
|
||||
if (!ok) {
|
||||
failed = true
|
||||
console.log("RESULT fail " + message)
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Network.Panel {
|
||||
id: panel
|
||||
bar: QtObject {
|
||||
property color foreground: Color.foreground
|
||||
property color barForeground: Color.foreground
|
||||
property color urgent: Color.urgent
|
||||
property string fontFamily: Style.font.family
|
||||
property string position: "top"
|
||||
property int barSize: 24
|
||||
property bool vertical: false
|
||||
property bool foregroundAnimationEnabled: false
|
||||
property var activePopout: null
|
||||
function requestPopout(owner) { activePopout = owner }
|
||||
function releasePopout(owner) { activePopout = null }
|
||||
function registerClickTarget(target) {}
|
||||
function unregisterClickTarget(target) {}
|
||||
function hideTooltip(target) {}
|
||||
function showTooltip(target, text) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unlist() { NetworkMock.wifi.networks = { values: [] } }
|
||||
function relist() { NetworkMock.wifi.networks = test.listed }
|
||||
|
||||
Timer {
|
||||
interval: 250
|
||||
running: true
|
||||
onTriggered: {
|
||||
test.check(panel.kind === "wifi" && panel.signalStrength === 80, "listed network supplies its own strength")
|
||||
test.check(!panel.testApPoll.running, "a network with its own strength adds no polling")
|
||||
test.check(panel.connectionKey === "wifi:test-wifi", "Wi-Fi connection key follows the device")
|
||||
test.checksBefore = NetworkMock.checks
|
||||
test.unlist()
|
||||
Qt.callLater(test.unlistedChecks)
|
||||
}
|
||||
}
|
||||
|
||||
function unlistedChecks() {
|
||||
check(panel.kind === "wifi", "connected device keeps Wi-Fi when no network is listed as connected")
|
||||
check(panel.icon !== "", "unlisted network does not flicker to the disconnected icon")
|
||||
check(panel.testApPoll.running, "missing strength starts the in-use access point poll")
|
||||
check(panel.connectionKey === "wifi:test-wifi", "connection key survives the listed network disappearing")
|
||||
firstReading.start()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: firstReading
|
||||
interval: 600
|
||||
onTriggered: {
|
||||
test.check(panel.activeApSignal === 57 && panel.signalStrength === 57, "in-use access point strength fills the gap")
|
||||
test.check(panel.icon === Model.wifiIconFor(57), "bar icon follows the in-use access point strength")
|
||||
test.relist()
|
||||
Qt.callLater(test.relistedChecks)
|
||||
}
|
||||
}
|
||||
|
||||
function relistedChecks() {
|
||||
check(panel.signalStrength === 80 && !panel.testApPoll.running, "a relisted network takes over and stops the poll")
|
||||
check(NetworkMock.checks === checksBefore, "scan churn schedules no extra connectivity checks")
|
||||
// Second nmcli read is slow; drop the link while it is in flight.
|
||||
unlist()
|
||||
Qt.callLater(dropWhileReading)
|
||||
}
|
||||
|
||||
function dropWhileReading() {
|
||||
NetworkMock.wifi.connected = false
|
||||
Qt.callLater(function() {
|
||||
check(panel.kind === "disconnected" && panel.icon === "", "a real disconnect still shows disconnected")
|
||||
check(panel.activeApSignal === -1 && !panel.testApPoll.running, "disconnect clears the cached strength and stops polling")
|
||||
NetworkMock.wifi.connected = true
|
||||
staleReading.start()
|
||||
})
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: staleReading
|
||||
interval: 1500
|
||||
onTriggered: {
|
||||
test.check(panel.activeApSignal === 33, "a read from before the reset is discarded and re-read (got " + panel.activeApSignal + ")")
|
||||
NetworkMock.wifi.connected = false
|
||||
Qt.callLater(test.finalChecks)
|
||||
}
|
||||
}
|
||||
|
||||
function finalChecks() {
|
||||
check(panel.activeApSignal === -1 && !panel.testApPoll.running, "final disconnect clears the reading")
|
||||
if (!failed) console.log("RESULT pass")
|
||||
done.start()
|
||||
}
|
||||
|
||||
Timer { id: done; interval: 200; onTriggered: Qt.quit() }
|
||||
}
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_compositor "network OWE transition runtime test"
|
||||
require_command quickshell
|
||||
|
||||
stage=$(mktemp -d)
|
||||
trap 'rm -rf -- "$stage"' EXIT
|
||||
fixture="$SHELL_TEST_DIR/fixtures/network-owe-transition"
|
||||
mkdir -p "$stage/network" "$stage/bin" "$stage/home"
|
||||
ln -s "$ROOT/shell/Ui" "$stage/Ui"
|
||||
ln -s "$ROOT/shell/Commons" "$stage/Commons"
|
||||
cp -r "$SHELL_TEST_DIR/fixtures/network-captive-portal/mocks" "$stage/mocks"
|
||||
cp "$fixture/shell.qml" "$stage/shell.qml"
|
||||
cp "$ROOT/shell/plugins/panels/network/Model.js" "$stage/network/Model.js"
|
||||
node - "$ROOT" "$stage" <<'JS'
|
||||
const fs = require('fs')
|
||||
const [root, stage] = process.argv.slice(2)
|
||||
let source = fs.readFileSync(`${root}/shell/plugins/panels/network/Panel.qml`, 'utf8')
|
||||
// Replace only the singleton and expose the private poll in the disposable copy.
|
||||
source = source.replace('import Quickshell.Networking', 'import Quickshell.Networking\nimport "../mocks"')
|
||||
source = source.replace(/\bNetworking\./g, 'NetworkMock.')
|
||||
source = source.replace(' id: root', ' id: root\n property alias testApPoll: activeApSignalPoll')
|
||||
fs.writeFileSync(`${stage}/network/Panel.qml`, source)
|
||||
JS
|
||||
printf '#!/bin/bash\nexit 0\n' > "$stage/bin/noop"
|
||||
chmod +x "$stage/bin/noop"
|
||||
for command in omarchy-dns omarchy-network-band omarchy-network-status; do
|
||||
ln -s noop "$stage/bin/$command"
|
||||
done
|
||||
# Answers in call order: 57, then a slow stale 11, then 33. Never touches the
|
||||
# host's NetworkManager.
|
||||
cat > "$stage/bin/nmcli" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >> "$NETWORK_TEST_NMCLI_LOG"
|
||||
count=$(wc -l < "$NETWORK_TEST_NMCLI_LOG")
|
||||
case $count in
|
||||
1) printf ' :82\n*:57\n' ;;
|
||||
2) sleep 0.5; printf '*:11\n' ;;
|
||||
*) printf ' :40\n*:33\n' ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$stage/bin/nmcli"
|
||||
|
||||
output=$(HOME="$stage/home" OMARCHY_PATH="$ROOT" PATH="$stage/bin:$PATH" \
|
||||
NETWORK_TEST_NMCLI_LOG="$stage/nmcli.log" \
|
||||
timeout 30 quickshell -p "$stage" --no-color 2>&1) || fail "network OWE fixture exits cleanly" "$output"
|
||||
[[ $output == *"RESULT pass"* ]] || fail "network OWE runtime assertions pass" "$output"
|
||||
if rg -q 'RESULT fail|ReferenceError|TypeError|Error:|Unable to assign|Binding loop' <<< "$output"; then
|
||||
fail "network OWE fixture has no QML errors" "$output"
|
||||
fi
|
||||
while IFS= read -r args; do
|
||||
[[ $args == "-t -f IN-USE,SIGNAL device wifi list ifname test-wifi --rescan no" ]] || fail "in-use access point read never rescans" "$args"
|
||||
done < "$stage/nmcli.log"
|
||||
pass "network keeps Wi-Fi through OWE transition scan churn, reads the in-use access point, and discards stale reads"
|
||||
@@ -90,6 +90,27 @@ assertDeepEqual(
|
||||
'network parses bar status'
|
||||
)
|
||||
assertEqual(network.connectionIcon('wifi', 80), network.wifiIconFor(80), 'network maps wifi icon from signal')
|
||||
|
||||
// OWE transition mode: between scans NetworkManager files the in-use access
|
||||
// point under the hidden "_owetm_" SSID and drops the active profile from the
|
||||
// device's AvailableConnections, so Quickshell lists no connected network. The
|
||||
// bar trusts the device's own state and reads the in-use AP's strength instead
|
||||
// of flickering to disconnected.
|
||||
const kindBinding = panelSource.match(/readonly property string kind: \{[\s\S]*?\n {2}\}/)
|
||||
assert(kindBinding, 'network has a kind binding')
|
||||
assert(/if \(wifiDevice && wifiDevice\.connected\) return "wifi"/.test(kindBinding[0]), 'network keeps showing Wi-Fi while the device is connected but no network is listed as connected')
|
||||
assert(
|
||||
kindBinding[0].indexOf('connectedWifiNetwork) return "wifi"') < kindBinding[0].indexOf('wifiDevice.connected) return "wifi"'),
|
||||
'network prefers the listed connected network before falling back to device state'
|
||||
)
|
||||
assertEqual(network.connectedSignalStrength(0.8, 40), 80, 'network prefers the connected network strength')
|
||||
assertEqual(network.connectedSignalStrength(0, 67), 67, 'network falls back to the in-use access point strength')
|
||||
assertEqual(network.connectedSignalStrength(0, -1), 0, 'network reports no strength when the in-use access point is unknown')
|
||||
assertEqual(network.parseActiveApSignal(' :82\n*:67\n :50\n'), 67, 'network parses the in-use access point strength from nmcli')
|
||||
assertEqual(network.parseActiveApSignal(' :82\n'), -1, 'network reports no in-use access point when none is marked')
|
||||
assertEqual(network.parseActiveApSignal(''), -1, 'network tolerates empty nmcli output')
|
||||
assert(/running: root\.needsActiveApSignal/.test(panelSource), 'network only polls the in-use access point while the connected network lacks a strength')
|
||||
assert(/"--rescan", "no"/.test(panelSource), 'network reads the in-use access point without triggering a scan')
|
||||
assertEqual(network.formatHeaderSpeed('1000'), '1gbit', 'network formats gigabit speed')
|
||||
assertEqual(network.formatHeaderSpeed('2500'), '2.5gbit', 'network formats fractional gigabit speed')
|
||||
assertEqual(network.formatHeaderFreq('2462'), '2.4ghz', 'network formats 2.4GHz wifi band')
|
||||
|
||||
Reference in new issue
Block a user