diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 0cda97ef..343357b4 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -22,6 +22,13 @@ Panel { property var pendingActions: ({}) readonly property var adapter: Bluetooth.defaultAdapter + + // True while this instance owes BlueZ a StopDiscovery: set when it starts + // discovery (or opens onto a session already running) and cleared once + // discovery is confirmed down after close. Ownership, not state — BlueZ's + // Discovering property also reflects sessions other clients hold, which are + // never this panel's to stop. + property bool owesDiscoveryStop: false readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] readonly property var pipewireNodes: Pipewire.nodes ? Pipewire.nodes.values : [] property var pendingAudioOutputDevice: null @@ -400,6 +407,10 @@ Panel { onOpenedChanged: { if (opened) { + // Adopt a discovery session that is already running — a popout handoff + // from another monitor, or one leaked by an instance that could not + // finish its own stop — so this close settles it either way. + if (adapter !== null && adapter.discovering) owesDiscoveryStop = true if (connectedDevices.length > 0) { focusSection = "connected"; selectedIndex = 0 } else if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 } else if (discoveredDevices.length > 0) { focusSection = "discovered"; selectedIndex = 0 } @@ -409,6 +420,19 @@ Panel { } } + // Another per-monitor instance of this widget whose panel is open, if any. + // All instances share the default adapter, and switching the popout to a + // different monitor closes one instance as it opens the next, so the + // closing side has to leave the scan alone for the side still on screen. + function openSibling() { + if (!bar || typeof bar.moduleWidgets !== "function") return null + var items = bar.moduleWidgets(moduleName) + for (var i = 0; i < items.length; i++) { + if (items[i] && items[i] !== root && items[i].opened === true) return items[i] + } + return null + } + function updateFocusedAddress() { var d = deviceAt(focusSection, selectedIndex) focusedDeviceAddress = d ? (d.address || "") : "" @@ -486,7 +510,71 @@ Panel { repeat: true triggeredOnStart: true running: root.opened && root.adapter !== null && root.adapter.enabled && !root.adapter.discovering - onTriggered: root.adapter.discovering = true + onTriggered: { + root.owesDiscoveryStop = true + root.adapter.discovering = true + } + } + + // The way back down. The BlueZ discovery session behind adapter.discovering + // is held by quickshell's D-Bus connection, so nothing ends it at close: + // without this timer, one visit to the panel left the radio in inquiry + // until the next shell restart, starving A2DP audio on the same controller + // into stutters. + // + // A timer bound to the confirmed state rather than a write at close time: + // quickshell only forwards a discovering write that differs from the last + // state BlueZ reported, so a stop issued while a just-fired StartDiscovery + // is still awaiting confirmation would be swallowed and leak the session. + // Binding to adapter.discovering means a confirmation landing at any point + // after close re-arms the stop, and a reopen inside the first interval + // keeps the scan running uninterrupted. Attempts are bounded so a session + // some other BlueZ client keeps up cannot draw StopDiscovery fire forever. + Timer { + id: discoveryStop + interval: 1000 + repeat: true + property int attempts: 0 + running: !root.opened && root.owesDiscoveryStop && root.adapter !== null && root.adapter.discovering === true + onRunningChanged: if (running) attempts = 0 + onTriggered: { + // The scan now serves the open panel, so the debt moves with it — B may + // have opened before BlueZ confirmed A's start, in which case B's own + // open-time adoption saw nothing to adopt. + var sibling = root.openSibling() + if (sibling) { + sibling.owesDiscoveryStop = true + root.owesDiscoveryStop = false + return + } + attempts += 1 + if (attempts > 3) { root.owesDiscoveryStop = false; return } + root.adapter.discovering = false + } + } + + // The debt is settled the moment BlueZ reports discovery down — whether + // because the stop above landed or the session ended some other way — so a + // stale claim never touches a scan another client starts later. While the + // panel is open, discoveryRetry re-incurs it as it restarts the scan. + Connections { + target: root.adapter + function onDiscoveringChanged() { + if (!root.adapter.discovering) root.owesDiscoveryStop = false + } + } + + // A destroyed instance cannot wait for BlueZ confirmations, so it hands any + // debt to a surviving sibling — whose declarative stop catches even a start + // confirmed after this object is gone — and only writes the stop directly + // when it is the last one standing. + Component.onDestruction: { + if (!owesDiscoveryStop) return + var items = bar && typeof bar.moduleWidgets === "function" ? bar.moduleWidgets(moduleName) : [] + for (var i = 0; i < items.length; i++) { + if (items[i] && items[i] !== root) { items[i].owesDiscoveryStop = true; return } + } + if (adapter !== null && adapter.discovering) adapter.discovering = false } Timer { diff --git a/test/shell.d/bluetooth-test.sh b/test/shell.d/bluetooth-test.sh index d66b8083..b20727e6 100644 --- a/test/shell.d/bluetooth-test.sh +++ b/test/shell.d/bluetooth-test.sh @@ -20,6 +20,36 @@ assert(/manageIpc: false/.test(panelSource), 'bluetooth owns its IPC handler so assert(/function toggleBluetooth\(\)[\s\S]*?execDetached\(\["omarchy-bluetooth-power", adapter\.enabled \? "off" : "on"\]\)/.test(panelSource), 'bluetooth toggles the radio through the rfkill soft block') assert(!/adapter\.enabled = /.test(panelSource), 'bluetooth never writes the adapter power state directly') +// Discovery is a BlueZ session that nothing ends at panel close: it persists +// until StopDiscovery or until quickshell's D-Bus connection drops with the +// shell, and a leaked session keeps the radio in inquiry, starving A2DP audio +// on the same controller. The panel tracks the stop it owes and settles it +// once closed. +const retryTimer = panelSource.match(/id: discoveryRetry[\s\S]*?onTriggered: \{[\s\S]*?\n {4}\}/) +assert(retryTimer, 'bluetooth has the discovery retry timer') +assert(/owesDiscoveryStop = true/.test(retryTimer[0]), 'bluetooth takes on the stop it owes when it starts discovery') + +// Quickshell only forwards a discovering write that differs from BlueZ's last +// confirmed state, so a stop written in the same instant as an in-flight +// StartDiscovery would be swallowed. Binding the stop timer to the confirmed +// state means a confirmation landing at any point after close re-arms it. +const stopTimer = panelSource.match(/id: discoveryStop[\s\S]*?onTriggered: \{[\s\S]*?\n {4}\}/) +assert(stopTimer, 'bluetooth has the discovery stop timer') +assert(/running: !root\.opened && root\.owesDiscoveryStop[\s\S]*discovering === true/.test(stopTimer[0]), 'bluetooth arms the stop off the confirmed discovery state while closed') +assert(/discovering = false/.test(stopTimer[0]), 'bluetooth stops discovery after the panel closes') + +// One widget instance exists per monitor and they share the default adapter, +// so a closing instance hands the scan to a panel still open on another +// monitor instead of stopping it — that is the popout handoff between +// monitors. +assert(/function openSibling\(\)/.test(panelSource), 'bluetooth can see panel instances on other monitors') +assert(/sibling\.owesDiscoveryStop = true/.test(stopTimer[0]), 'bluetooth moves the stop it owes to an open panel on another monitor instead of stopping its scan') + +// The debt clears when BlueZ confirms discovery down, and a destroyed +// instance hands it to a surviving sibling instead of taking it to the grave. +assert(/onDiscoveringChanged[\s\S]{0,120}owesDiscoveryStop = false/.test(panelSource), 'bluetooth settles the stop it owes once discovery is confirmed down') +assert(/Component\.onDestruction: \{[\s\S]{0,400}owesDiscoveryStop = true[\s\S]{0,200}discovering = false/.test(panelSource), 'bluetooth passes the stop it owes to a sibling when an instance is destroyed') + assert(bluetooth.isUuidLike('0000110b-0000-1000-8000-00805f9b34fb'), 'bluetooth detects UUID-like names') assert(bluetooth.isAddressLike('AA:BB:CC:DD:EE:FF'), 'bluetooth detects address-like names') assertEqual(bluetooth.normalizedAddress('AA:BB_CC-dd-ee-ff'), 'aabbccddeeff', 'bluetooth normalizes BlueZ and PipeWire address formats')