The discovery retry timer turned adapter.discovering on every second while the panel was open, and nothing ever turned it off. The BlueZ discovery session behind it is held by quickshell's D-Bus connection, so one visit to the panel left the radio in inquiry until the next shell restart — continuously starving A2DP audio on the same controller into stuttering, and 'bluetoothctl show' kept reporting 'Discovering: yes' long after the panel was gone. The panel now tracks the StopDiscovery it owes BlueZ and settles it once closed. A timer bound to the confirmed discovery state does the stopping, rather than a write in the close handler: 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 re-arms the stop whenever the confirmation lands, a reopen inside the first interval keeps the scan running uninterrupted, and attempts are bounded so a session another BlueZ client holds up cannot draw StopDiscovery calls forever. One widget instance exists per monitor and they all share the default adapter — the same shared-backend shape the network panel's wifi scanner fix (#6772) dealt with — so the debt follows the session: an instance opening onto a running scan adopts it, a closing instance hands it to a panel still open on another monitor (the popout handoff closes one instance as it opens the next), and a destroyed instance passes it to a surviving sibling. Fixes #6789 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
283 lines
13 KiB
Bash
283 lines
13 KiB
Bash
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
|
|
grep -q '^ConditionPathIsDirectory=/sys/class/bluetooth$' "$ROOT/default/systemd/user/bt-agent.service" || \
|
|
fail "bt-agent is skipped on machines without Bluetooth hardware"
|
|
pass "bt-agent is skipped on machines without Bluetooth hardware"
|
|
|
|
run_node_test <<'JS'
|
|
const fs = require('fs')
|
|
const bluetooth = requireFromRoot('shell/plugins/panels/bluetooth/Model.js')
|
|
const panelSource = fs.readFileSync(root + '/shell/plugins/panels/bluetooth/Panel.qml', 'utf8')
|
|
|
|
assert(/IpcHandler[\s\S]*?function toggleBluetooth\(\) \{ root\.toggleBluetooth\(\) \}/.test(panelSource), 'bluetooth exposes the radio toggle over IPC')
|
|
assert(/manageIpc: false/.test(panelSource), 'bluetooth owns its IPC handler so it can extend the target methods')
|
|
|
|
// Writing adapter.enabled sets BlueZ Powered, which does not survive a reboot.
|
|
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')
|
|
assert(!bluetooth.hasHumanName({ name: 'AA:BB:CC:DD:EE:FF' }), 'bluetooth rejects address-only device labels')
|
|
assert(bluetooth.hasHumanName({ deviceName: 'MX Master 3S' }), 'bluetooth accepts human device labels')
|
|
|
|
const devices = [
|
|
{ name: 'Speaker', connected: false, paired: true, address: '2' },
|
|
{ name: 'Headphones', connected: true, address: '1' },
|
|
{ name: 'Keyboard', connected: false, address: '3' },
|
|
{ name: 'AA:BB:CC:DD:EE:FF', connected: true, address: '4' },
|
|
{ name: 'Mouse', connected: false, trusted: true, address: '5' }
|
|
]
|
|
|
|
const arrayLikeDevices = {
|
|
0: devices[0],
|
|
1: devices[1],
|
|
length: 2
|
|
}
|
|
assertDeepEqual(
|
|
bluetooth.toArray(arrayLikeDevices).map(bluetooth.deviceLabel),
|
|
['Speaker', 'Headphones'],
|
|
'bluetooth converts Quickshell QObjectList-style values into arrays'
|
|
)
|
|
|
|
const lists = bluetooth.deviceLists(devices)
|
|
assertDeepEqual(lists.connected.map(bluetooth.deviceLabel), ['Headphones'], 'bluetooth groups connected devices')
|
|
assertDeepEqual(lists.known.map(bluetooth.deviceLabel), ['Mouse', 'Speaker'], 'bluetooth groups known devices by label')
|
|
assertDeepEqual(lists.discovered.map(bluetooth.deviceLabel), ['Keyboard'], 'bluetooth groups discovered devices')
|
|
assertDeepEqual(bluetooth.visibleSections(lists, true), ['connected', 'known', 'discovered'], 'bluetooth shows discovered section while scanning')
|
|
assertDeepEqual(bluetooth.visibleSections(lists, false), ['connected', 'known'], 'bluetooth hides discovered section when not scanning')
|
|
|
|
const arrayLikeLists = bluetooth.deviceLists({
|
|
0: { name: 'Earbuds', connected: true, address: '6' },
|
|
1: { name: 'Trackpad', paired: true, address: '7' },
|
|
2: { name: 'Gamepad', address: '8' },
|
|
length: 3
|
|
})
|
|
assertDeepEqual(arrayLikeLists.connected.map(bluetooth.deviceLabel), ['Earbuds'], 'bluetooth groups connected devices from array-like values')
|
|
assertDeepEqual(arrayLikeLists.known.map(bluetooth.deviceLabel), ['Trackpad'], 'bluetooth groups known devices from array-like values')
|
|
assertDeepEqual(arrayLikeLists.discovered.map(bluetooth.deviceLabel), ['Gamepad'], 'bluetooth groups discovered devices from array-like values')
|
|
|
|
assertDeepEqual(
|
|
bluetooth.deviceRow({ name: 'Deadbeef', address: '1', connected: false }),
|
|
{ address: '1', name: 'Deadbeef', deviceName: '', connected: false, state: -1, batteryAvailable: false, battery: 0, pairing: false },
|
|
'bluetooth projects device rows with primitives only'
|
|
)
|
|
assertEqual(
|
|
bluetooth.deviceLabel(bluetooth.deviceRow({ name: 'Generic', deviceName: 'MX Master 3S', address: '2', connected: true })),
|
|
'MX Master 3S',
|
|
'bluetooth keeps deviceName in row projections so labels survive QObject-free rows'
|
|
)
|
|
|
|
assertDeepEqual(
|
|
bluetooth.withPendingAction({ a: 'connecting' }, 'b', 'forgetting'),
|
|
{ a: 'connecting', b: 'forgetting' },
|
|
'bluetooth adds pending actions immutably'
|
|
)
|
|
assertDeepEqual(bluetooth.withPendingAction({ a: 'connecting' }, 'a', ''), {}, 'bluetooth clears pending actions immutably')
|
|
|
|
const bluetoothSink = {
|
|
isSink: true,
|
|
isStream: false,
|
|
ready: true,
|
|
name: 'bluez_output.AA_BB_CC_DD_EE_FF.1',
|
|
properties: {
|
|
'device.product.name': 'JBL Go 3'
|
|
}
|
|
}
|
|
assert(
|
|
bluetooth.bluetoothSinkMatchesDevice(bluetoothSink, { address: 'AA:BB:CC:DD:EE:FF', name: 'JBL Go 3' }),
|
|
'bluetooth matches audio sinks by device address'
|
|
)
|
|
assert(
|
|
bluetooth.bluetoothSinkMatchesDevice(
|
|
{
|
|
isSink: true,
|
|
isStream: false,
|
|
ready: true,
|
|
name: 'alsa_output.usb-speaker',
|
|
properties: { 'device.product.name': 'JBL Go 3' }
|
|
},
|
|
{ address: '11:22:33:44:55:66', name: 'JBL Go 3' }
|
|
),
|
|
'bluetooth matches audio sinks by human device label when address is unavailable'
|
|
)
|
|
assert(
|
|
!bluetooth.bluetoothSinkMatchesDevice({ isSink: false, isStream: false, ready: true, name: 'bluez_output.AA_BB_CC_DD_EE_FF.1', properties: {} }, { address: 'AA:BB:CC:DD:EE:FF', name: 'JBL Go 3' }),
|
|
'bluetooth ignores non-sink nodes when matching audio outputs'
|
|
)
|
|
JS
|
|
|
|
# Turning Bluetooth off is an rfkill soft block, not a bluetoothctl power off,
|
|
# because only the block survives a reboot. These mocks stand in for that pair:
|
|
# rfkill moves the block, and bluetoothd powers the adapter up once it is gone.
|
|
device_tmp=$(mktemp -d)
|
|
trap 'rm -rf "$device_tmp"' EXIT
|
|
|
|
mock_bin="$device_tmp/bin"
|
|
mkdir -p "$mock_bin"
|
|
export POWERED_FILE="$device_tmp/powered"
|
|
|
|
cat >"$mock_bin/bluetoothctl" <<'SH'
|
|
#!/bin/bash
|
|
|
|
printf '%s\n' "$*" >>"$BLUETOOTHCTL_LOG"
|
|
[[ $1 == "power" && $2 == "on" ]] && echo yes >"$POWERED_FILE"
|
|
[[ $1 == "list" ]] &&
|
|
for c in ${MOCK_CONTROLLERS:-AA:BB:CC:DD:EE:FF}; do printf 'Controller %s mock\n' "$c"; done
|
|
# Per-controller state where a test set it, the shared file otherwise.
|
|
if [[ $1 == "show" ]]; then
|
|
state="$POWERED_FILE"
|
|
[[ -n ${2:-} && -f "$POWERED_FILE.$2" ]] && state="$POWERED_FILE.$2"
|
|
printf '\tPowered: %s\n' "$(cat "$state")"
|
|
fi
|
|
exit 0
|
|
SH
|
|
|
|
cat >"$mock_bin/rfkill" <<'SH'
|
|
#!/bin/bash
|
|
|
|
printf 'rfkill %s\n' "$*" >>"$BLUETOOTHCTL_LOG"
|
|
# Lifting the block is normally all it takes: AutoEnable is left at its default,
|
|
# so bluetoothd powers the adapter up on its own. RFKILL_INERT stands in for the
|
|
# adapter that was powered down without a block, where it does not.
|
|
[[ $1 == "unblock" && -z ${RFKILL_INERT:-} ]] && echo yes >"$POWERED_FILE"
|
|
[[ $1 == "block" ]] && echo no >"$POWERED_FILE"
|
|
exit 0
|
|
SH
|
|
|
|
chmod +x "$mock_bin/bluetoothctl" "$mock_bin/rfkill"
|
|
|
|
# $ROOT/bin so omarchy-bluetooth-device resolves the real omarchy-bluetooth-power.
|
|
bluetooth_run() {
|
|
local powered="$1"
|
|
shift
|
|
|
|
echo "$powered" >"$POWERED_FILE"
|
|
: >"$device_tmp/log"
|
|
PATH="$mock_bin:$ROOT/bin:$PATH" BLUETOOTHCTL_LOG="$device_tmp/log" \
|
|
OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS=0 "$@" ||
|
|
fail "$* exits cleanly with Powered: $powered"
|
|
printf '%s' "$device_tmp/log"
|
|
}
|
|
|
|
bluetooth_power() {
|
|
bluetooth_run "$1" "$ROOT/bin/omarchy-bluetooth-power" "$2"
|
|
}
|
|
|
|
# Off has to be the block. A bluetoothctl power off would read the same until the
|
|
# next boot, then quietly come back on.
|
|
off_log=$(bluetooth_power yes off)
|
|
grep -qx "rfkill block bluetooth" "$off_log" ||
|
|
fail "bluetooth turns off with an rfkill block" "$(cat "$off_log")"
|
|
pass "bluetooth turns off with an rfkill block"
|
|
|
|
grep -q "power off" "$off_log" &&
|
|
fail "bluetooth does not also power the adapter down" "$(cat "$off_log")"
|
|
pass "bluetooth does not also power the adapter down"
|
|
|
|
# Unblocking is enough on its own, so there is nothing left to ask bluetoothctl.
|
|
on_log=$(bluetooth_power no on)
|
|
grep -qx "rfkill unblock bluetooth" "$on_log" ||
|
|
fail "bluetooth turns on by lifting the block" "$(cat "$on_log")"
|
|
pass "bluetooth turns on by lifting the block"
|
|
|
|
grep -q "power on" "$on_log" &&
|
|
fail "bluetooth leaves the power-on to bluetoothd when the block is lifted" "$(cat "$on_log")"
|
|
pass "bluetooth leaves the power-on to bluetoothd when the block is lifted"
|
|
|
|
# An adapter powered down without a block is one bluetoothd will not pick up.
|
|
inert_log=$(RFKILL_INERT=1 bluetooth_power no on)
|
|
grep -qx "power on" "$inert_log" ||
|
|
fail "bluetooth powers the adapter on when unblocking does not" "$(cat "$inert_log")"
|
|
pass "bluetooth powers the adapter on when unblocking does not"
|
|
|
|
# The panel switch reads Powered, so that is what toggle has to invert.
|
|
toggle_on_log=$(bluetooth_power yes toggle)
|
|
grep -qx "rfkill block bluetooth" "$toggle_on_log" ||
|
|
fail "bluetooth toggles a powered adapter off" "$(cat "$toggle_on_log")"
|
|
pass "bluetooth toggles a powered adapter off"
|
|
|
|
toggle_off_log=$(bluetooth_power no toggle)
|
|
grep -qx "rfkill unblock bluetooth" "$toggle_off_log" ||
|
|
fail "bluetooth toggles an unpowered adapter on" "$(cat "$toggle_off_log")"
|
|
pass "bluetooth toggles an unpowered adapter on"
|
|
|
|
# The power-on shortcut is the whole point of skipping the stabilization sleep:
|
|
# pair/connect from the panel run against an adapter that is already powered.
|
|
bluetooth_device_log() {
|
|
bluetooth_run "$1" "$ROOT/bin/omarchy-bluetooth-device" connect AA:BB:CC:DD:EE:FF
|
|
}
|
|
|
|
powered_log=$(bluetooth_device_log yes)
|
|
grep -q "rfkill" "$powered_log" &&
|
|
fail "bluetooth skips the power-on delay when the adapter is already powered"
|
|
pass "bluetooth skips the power-on delay when the adapter is already powered"
|
|
|
|
grep -qx "connect AA:BB:CC:DD:EE:FF" "$powered_log" ||
|
|
fail "bluetooth still connects when the adapter is already powered"
|
|
pass "bluetooth still connects when the adapter is already powered"
|
|
|
|
# Connecting to a device while Bluetooth is off has to lift the block first —
|
|
# BlueZ refuses to power an adapter up while one is set.
|
|
unpowered_log=$(bluetooth_device_log no)
|
|
grep -qx "rfkill unblock bluetooth" "$unpowered_log" ||
|
|
fail "bluetooth lifts the block before connecting" "$(cat "$unpowered_log")"
|
|
pass "bluetooth lifts the block before connecting"
|
|
|
|
grep -qx "connect AA:BB:CC:DD:EE:FF" "$unpowered_log" ||
|
|
fail "bluetooth connects once the adapter is up" "$(cat "$unpowered_log")"
|
|
pass "bluetooth connects once the adapter is up"
|
|
|
|
# Blocking hits every radio at once, so the read has to span them too. A bare
|
|
# bluetoothctl show reports the default controller and misses a powered dongle.
|
|
echo yes >"$POWERED_FILE.11:22:33:44:55:66"
|
|
export MOCK_CONTROLLERS="AA:BB:CC:DD:EE:FF 11:22:33:44:55:66"
|
|
multi_log=$(bluetooth_power no toggle)
|
|
unset MOCK_CONTROLLERS
|
|
rm -f "$POWERED_FILE.11:22:33:44:55:66"
|
|
|
|
grep -qx "rfkill block bluetooth" "$multi_log" ||
|
|
fail "bluetooth counts a secondary controller as on" "$(cat "$multi_log")"
|
|
pass "bluetooth counts a secondary controller as on"
|
|
|
|
# AutoEnable=false was the old attempt at persistence and never worked. Left set,
|
|
# it would also keep bluetoothd from powering the adapter up after an unblock.
|
|
grep -q 'AutoEnable=false' "$ROOT/install/hardware/bluetooth.sh" &&
|
|
fail "bluetooth install leaves AutoEnable at its default"
|
|
pass "bluetooth install leaves AutoEnable at its default"
|