diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 2ed28294..9cb737e6 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -292,6 +292,29 @@ Panel { readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" + // scannerEnabled lives on the shared WifiDevice, which has no reference + // counting, and a bar widget is instantiated once per monitor. Tracking the + // device this instance turned scanning on for keeps the release correct when + // the panel closes, the device is replaced, or the widget is destroyed — + // without a closed instance ever claiming the scanner. + property var scannerDevice: null + + function setScannerEnabled(enabled) { + var nextDevice = opened ? wifiDevice : null + + if (scannerDevice && scannerDevice !== nextDevice) + scannerDevice.scannerEnabled = false + + scannerDevice = nextDevice + + if (scannerDevice) + scannerDevice.scannerEnabled = enabled + } + + Component.onDestruction: { + if (scannerDevice) scannerDevice.scannerEnabled = false + } + // KeyboardPanel primes layer-shell focus whenever the panel opens. That's // what makes the SUPER+CTRL+W keybind land here with navigation ready. onOpenedChanged: { @@ -305,6 +328,10 @@ Panel { syncBandIndex() cursorActive = false } else { + // Drop a restart armed by this open: without it a close/reopen inside + // the 100ms window reuses the running timer and re-enables the scanner + // almost immediately, undoing the deferral #6605 restored. + scanRestart.stop() // Reset throughput tracking so the next open doesn't compute a fake // rate from a sample taken minutes ago. prevSampleTime = 0 @@ -316,7 +343,7 @@ Panel { routerPingLatency = -1 internetPingLatency = -1 internetPingPacketLoss = 0 - if (wifiDevice) wifiDevice.scannerEnabled = false + setScannerEnabled(false) } } @@ -357,7 +384,7 @@ Panel { } onWifiDeviceChanged: { - if (wifiDevice) wifiDevice.scannerEnabled = opened + setScannerEnabled(true) syncWifiNetworks() } @@ -452,13 +479,15 @@ Panel { bandProc.command = ["omarchy-network-band"] bandProc.running = true } - if (wifiDevice) { + // A closed panel has no nearby-network list to fill, and bare refresh() + // reaches here from action completion, timeouts and construction. + if (opened && wifiDevice) { if (scanWifi) { scanning = true - wifiDevice.scannerEnabled = false + setScannerEnabled(false) scanRestart.start() } else { - wifiDevice.scannerEnabled = true + setScannerEnabled(true) } } syncWifiNetworks() @@ -792,8 +821,10 @@ Panel { interval: 100 repeat: false onTriggered: { - if (root.wifiDevice) root.wifiDevice.scannerEnabled = true - scanDone.start() + if (root.opened && root.wifiDevice) { + root.setScannerEnabled(true) + scanDone.start() + } } } diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index c6b4a947..920ee9d1 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -21,6 +21,60 @@ assert(barPress, 'network bar button has an onPressed handler') const barPressCode = barPress[0].replace(/\/\/.*$/gm, '') assert(!/refresh\(/.test(barPressCode), 'network bar click opens the panel without a second refresh that would undo the deferred scan') +// A closed panel has no nearby-network list to fill. Quickshell's scanner +// re-arms RequestScan on its own timer, and every sweep takes the radio off +// the operating channel, so a scanner left enabled behind a closed panel keeps +// degrading the connection it is scanning from. +const refreshFn = panelSource.match(/function refresh\(scanWifi\)[\s\S]*?\n {2}\}/) +assert(refreshFn, 'network has a refresh() function') +assert(/if \(opened && wifiDevice\)/.test(refreshFn[0]), 'network only touches the scanner from refresh() while its panel is open') + +// The 100ms deferral can outlive the panel: closing inside the window would +// otherwise re-enable scanning from a timer nobody is watching. +const scanRestart = panelSource.match(/id: scanRestart[\s\S]*?onTriggered: \{[\s\S]*?\n {4}\}/) +assert(scanRestart, 'network has the deferred scan restart timer') +assert(/root\.opened/.test(scanRestart[0]), 'network re-checks the panel before the deferred restart re-enables scanning') +assert(/scanRestart\.stop\(\)/.test(panelSource), 'network cancels a pending scan restart when the panel closes') + +// scannerEnabled lives on a shared WifiDevice with no reference counting, so +// the panel has to own what it enabled. Run the helper's own JavaScript against +// stand-in devices: the two invariants it carries are that a closed panel never +// takes a device, and that adopting a new one releases the previous. +const scannerHelper = panelSource.match(/function setScannerEnabled\(enabled\) \{[\s\S]*?\n {2}\}/) +assert(scannerHelper, 'network has a scanner ownership helper') + +var opened = false +var wifiDevice = { scannerEnabled: false } +var scannerDevice = null +eval(scannerHelper[0]) + +setScannerEnabled(true) +assert( + scannerDevice === null && wifiDevice.scannerEnabled === false, + 'network does not let a closed panel claim or enable a scanner device' +) + +var previousScannerDevice = { scannerEnabled: true } +var replacementScannerDevice = { scannerEnabled: false } +opened = true +scannerDevice = previousScannerDevice +wifiDevice = replacementScannerDevice +setScannerEnabled(true) +assert( + previousScannerDevice.scannerEnabled === false && + scannerDevice === replacementScannerDevice && + replacementScannerDevice.scannerEnabled === true, + 'network releases the previous scanner device before enabling its replacement' +) + +// Destruction is the case a guard-only fix misses: the widget dies with the +// panel still open, as a bar reload does, and nothing else would release it. +assert( + /Component\.onDestruction[\s\S]{0,140}scannerDevice\.scannerEnabled = false/.test(panelSource), + 'network releases the scanner it owns when the widget is destroyed' +) +assert(!/wifiDevice\.scannerEnabled\s*=/.test(panelSource), 'network writes scanner state through its owned device reference rather than the moving wifiDevice reference') + // A row is a primitive snapshot that can outlive its WifiNetwork, and // disconnect() falls back to the live connection when handed null, so row // activation must go through the guarded disconnectRow().