diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index 5ed2c0d6..512ff969 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -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, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 87f160c1..a5ea65ee 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -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 diff --git a/test/shell.d/fixtures/network-owe-transition/shell.qml b/test/shell.d/fixtures/network-owe-transition/shell.qml new file mode 100644 index 00000000..5cbfcdcd --- /dev/null +++ b/test/shell.d/fixtures/network-owe-transition/shell.qml @@ -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() } +} diff --git a/test/shell.d/network-owe-transition-test.sh b/test/shell.d/network-owe-transition-test.sh new file mode 100755 index 00000000..dbeb3868 --- /dev/null +++ b/test/shell.d/network-owe-transition-test.sh @@ -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" diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index 11fdf9dd..fda7865a 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -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')