diff --git a/bin/omarchy-bluetooth-device b/bin/omarchy-bluetooth-device index 664e136d..d1f0b370 100755 --- a/bin/omarchy-bluetooth-device +++ b/bin/omarchy-bluetooth-device @@ -20,8 +20,9 @@ address=${2:-} power_on() { [[ $(timeout 2s bluetoothctl show 2>/dev/null) == *"Powered: yes"* ]] && return - bluetoothctl power on >/dev/null 2>&1 || true - sleep 0.5 + # Not bluetoothctl directly: Bluetooth is turned off by an rfkill soft block, + # and BlueZ refuses to power an adapter up while one is set. + omarchy-bluetooth-power on || true } trust_device() { diff --git a/bin/omarchy-bluetooth-power b/bin/omarchy-bluetooth-power new file mode 100755 index 00000000..7f30533b --- /dev/null +++ b/bin/omarchy-bluetooth-power @@ -0,0 +1,89 @@ +#!/bin/bash + +# omarchy:summary=Turn Bluetooth on or off, remembered across reboots +# omarchy:group=bluetooth +# omarchy:args= + +# BlueZ never persists an adapter's Powered property, so turning Bluetooth off +# through bluetoothctl lasts only until the next boot. The rfkill soft block does +# persist: systemd-rfkill saves every switch under /var/lib/systemd/rfkill and +# restores it early on the next boot, which is its entire job. Blocking is also +# what the kernel hands every radio at once, so a machine with two controllers +# gets both, where bluetoothctl only ever addresses the default one. +# +# So the block is the state, and BlueZ follows it: unblocking leaves AutoEnable +# at its stock default and bluetoothd powers the adapter up by itself. Every +# Omarchy path that turns Bluetooth on or off goes through here, because a plain +# `bluetoothctl power on` fails outright while the block is set. + +POWER_WAIT_SECONDS=${OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS:-2} + +controllers() { + timeout 2s bluetoothctl list 2>/dev/null | awk '{print $2}' +} + +# Any controller counts. The block is all-or-nothing across the radios, so the +# state has to be read the same way; a bare `bluetoothctl show` would report the +# default controller and miss a powered dongle sitting behind it. +powered() { + local controller + + for controller in $(controllers); do + [[ $(timeout 2s bluetoothctl show "$controller" 2>/dev/null) == *"Powered: yes"* ]] && return 0 + done + + return 1 +} + +# One deadline around the whole wait rather than a fixed number of probes: every +# probe can sit on its own timeout when D-Bus is wedged, and counting probes then +# stretches a two-second wait into half a minute. +wait_powered() { + local deadline=$((SECONDS + POWER_WAIT_SECONDS)) + + while :; do + powered && return 0 + ((SECONDS < deadline)) || return 1 + sleep 0.2 + done +} + +power_on() { + rfkill unblock bluetooth + + # Usually all it takes: with AutoEnable at its default, bluetoothd powers the + # adapter up on its own once the block is gone. It will not do that for an + # adapter powered down without a block, so ask directly before giving up. + wait_powered && return 0 + + timeout 5s bluetoothctl power on >/dev/null 2>&1 + wait_powered && return 0 + + echo "omarchy-bluetooth-power: adapter did not come up" >&2 + return 1 +} + +case "${1:-}" in + on) + power_on + ;; + off) + # No bluetoothctl power off to go with this: the block already drops the + # adapter to Powered: no, and it is the half that survives the reboot. + rfkill block bluetooth + ;; + toggle) + if powered; then + rfkill block bluetooth + else + power_on + fi + ;; + is-on) + powered + ;; + *) + echo "Usage: omarchy-bluetooth-power " >&2 + exit 1 + ;; +esac diff --git a/install/hardware/bluetooth.sh b/install/hardware/bluetooth.sh index 5ef25061..40345cf9 100644 --- a/install/hardware/bluetooth.sh +++ b/install/hardware/bluetooth.sh @@ -1,6 +1,7 @@ systemctl enable bluetooth.service -# Persist last power state across reboots (default AutoEnable=true overrides it) -if [[ -f /etc/bluetooth/main.conf ]]; then - sed -i 's/^#\?AutoEnable=.*/AutoEnable=false/' /etc/bluetooth/main.conf -fi +# AutoEnable stays at its stock default on purpose. It was set to false here to +# persist the power state, which it never did: BlueZ has no such behaviour, so +# all it bought was Bluetooth coming up off on every boot. omarchy-bluetooth-power +# holds the state in the rfkill soft block instead, and leaving AutoEnable alone +# is what lets bluetoothd bring the adapter back up when that block is lifted. diff --git a/migrations/1786380259.sh b/migrations/1786380259.sh new file mode 100644 index 00000000..ebe9af2c --- /dev/null +++ b/migrations/1786380259.sh @@ -0,0 +1,36 @@ +echo "Remember Bluetooth on and off through the rfkill soft block" + +marker="${OMARCHY_BLUETOOTH_MIGRATION_MARKER:-/var/lib/omarchy/migrations/1786380259}" +main_conf="${OMARCHY_BLUETOOTH_MAIN_CONF:-/etc/bluetooth/main.conf}" + +# Machine-wide work, but migration completion is recorded per user, so a second +# account would run it again and undo whatever an administrator changed in +# between. Written last, so an interrupted run is retried rather than skipped. +if [[ -e $marker ]]; then + exit 0 +fi + +# Read the machine as it stands before anything below changes it. Powered is the +# only record of what the user chose, and with AutoEnable=false holding the +# adapter down at every boot, no daemon to ask means off is what they have been +# living with. +# +# sudo because this runs machine-wide and /dev/rfkill is only writable without it +# from an active graphical seat — an update over SSH would otherwise abort here, +# before the marker, and abort again on every retry. +if omarchy-bluetooth-power is-on; then + sudo omarchy-bluetooth-power on +else + sudo omarchy-bluetooth-power off +fi + +# Omarchy set AutoEnable=false believing bluetoothd would then restore the last +# power state. It has no such behaviour, so all the flag ever did was keep +# Bluetooth off at every boot. Left in place it would also stop bluetoothd from +# powering the adapter up when the block above is lifted. Only the exact line +# Omarchy wrote is reverted, so a hand-edited opt-out survives. +if [[ -f $main_conf ]]; then + sudo sed -i 's/^AutoEnable=false$/#AutoEnable=true/' "$main_conf" +fi + +sudo install -Dm644 /dev/null "$marker" diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index a27c7cd7..0cda97ef 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -536,9 +536,17 @@ Panel { } } + // Not adapter.enabled: that writes BlueZ's Powered, which nothing persists, so + // the adapter came back on at the next boot. omarchy-bluetooth-power moves the + // rfkill soft block instead, which systemd-rfkill restores across reboots. + // Powered still follows the block, so the switch and icon read it as before. + // + // Asking for a direction rather than a toggle: the helper runs detached and the + // switch only moves once BlueZ catches up, so a second click inside that window + // would re-read the old state and undo the first. function toggleBluetooth() { if (!adapter) return - adapter.enabled = !adapter.enabled + Quickshell.execDetached(["omarchy-bluetooth-power", adapter.enabled ? "off" : "on"]) } IpcHandler { diff --git a/test/shell.d/bluetooth-migration-test.sh b/test/shell.d/bluetooth-migration-test.sh new file mode 100644 index 00000000..95d5b116 --- /dev/null +++ b/test/shell.d/bluetooth-migration-test.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1786380259.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/bin" + +# sudo runs the real command, so sed acts on the redirected main.conf below and +# the elevated power calls land in the stub beside it. +cat >"$test_dir/bin/sudo" <<'STUB' +#!/bin/bash + +printf 'sudo %s\n' "$*" >>"$CALLS" +exec "$@" +STUB + +cat >"$test_dir/bin/omarchy-bluetooth-power" <<'STUB' +#!/bin/bash + +printf 'omarchy-bluetooth-power %s\n' "$*" >>"$CALLS" +[[ $1 == "is-on" ]] || exit 0 +[[ ${POWERED:-} == "yes" ]] +STUB + +chmod +x "$test_dir/bin/"* + +export CALLS="$test_dir/calls" + +marker="$test_dir/marker" +main_conf="$test_dir/main.conf" + +reset_machine() { + rm -f "$marker" + printf '[Policy]\nAutoEnable=false\n' >"$main_conf" +} + +run_migration() { + : >"$CALLS" + + OMARCHY_BLUETOOTH_MIGRATION_MARKER="$marker" \ + OMARCHY_BLUETOOTH_MAIN_CONF="$main_conf" \ + PATH="$test_dir/bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null +} + +# An adapter that is powered right now is one the user turned on, so it stays on. +reset_machine +POWERED=yes run_migration + +grep -qx 'omarchy-bluetooth-power on' "$CALLS" || + fail "migration keeps a powered adapter on" "$(cat "$CALLS")" +pass "migration keeps a powered adapter on" + +grep -qx '#AutoEnable=true' "$main_conf" || + fail "migration puts AutoEnable back to its default" "$(cat "$main_conf")" +pass "migration puts AutoEnable back to its default" + +[[ -e $marker ]] || fail "migration records the machine as done" +pass "migration records the machine as done" + +# Anything else is a machine that has been booting with Bluetooth off, and the +# block is what carries that over now AutoEnable no longer holds the adapter down. +reset_machine +POWERED=no run_migration + +grep -qx 'omarchy-bluetooth-power off' "$CALLS" || + fail "migration carries an unpowered adapter over to the block" "$(cat "$CALLS")" +pass "migration carries an unpowered adapter over to the block" + +# No daemon to ask reads the same way: off is what the machine has been doing. +reset_machine +run_migration + +grep -qx 'omarchy-bluetooth-power off' "$CALLS" || + fail "migration blocks when no adapter can be read" "$(cat "$CALLS")" +pass "migration blocks when no adapter can be read" + +# /dev/rfkill is only writable unelevated from an active graphical seat, so an +# update run over SSH would abort here and abort again on every retry. +grep -qx 'sudo omarchy-bluetooth-power off' "$CALLS" || + fail "migration changes the radio through sudo" "$(cat "$CALLS")" +pass "migration changes the radio through sudo" + +# A second account must not undo an administrator's later choice, since migration +# completion is recorded per user. +printf '[Policy]\nAutoEnable=false\n' >"$main_conf" +POWERED=yes run_migration + +grep -qx 'AutoEnable=false' "$main_conf" || + fail "migration leaves a later opt-out alone" "$(cat "$main_conf")" +pass "migration leaves a later opt-out alone" + +[[ ! -s $CALLS ]] || + fail "migration touches no radio state on a second run" "$(cat "$CALLS")" +pass "migration touches no radio state on a second run" + +# Only the exact line Omarchy wrote is reverted, so a hand-edited opt-out stands. +reset_machine +printf '[Policy]\nAutoEnable = false\n' >"$main_conf" +POWERED=yes run_migration + +grep -qx 'AutoEnable = false' "$main_conf" || + fail "migration keeps a hand-edited AutoEnable" "$(cat "$main_conf")" +pass "migration keeps a hand-edited AutoEnable" diff --git a/test/shell.d/bluetooth-test.sh b/test/shell.d/bluetooth-test.sh index 9a3b8fe8..d66b8083 100644 --- a/test/shell.d/bluetooth-test.sh +++ b/test/shell.d/bluetooth-test.sh @@ -16,6 +16,10 @@ const panelSource = fs.readFileSync(root + '/shell/plugins/panels/bluetooth/Pane 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') + 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') @@ -108,36 +112,109 @@ assert( ) JS -# 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. +# 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 == "show" ]] && printf '\tPowered: %s\n' "$BLUETOOTHCTL_POWERED" +[[ $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 -chmod +x "$mock_bin/bluetoothctl" -bluetooth_device_log() { +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" - local log="$device_tmp/$powered.log" + shift - : >"$log" - PATH="$mock_bin:$PATH" BLUETOOTHCTL_LOG="$log" BLUETOOTHCTL_POWERED="$powered" \ - "$ROOT/bin/omarchy-bluetooth-device" connect AA:BB:CC:DD:EE:FF || - fail "omarchy-bluetooth-device exits cleanly with Powered: $powered" - printf '%s' "$log" + 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 -qx "power on" "$powered_log" && +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" @@ -145,7 +222,31 @@ 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 "power on" "$unpowered_log" || - fail "bluetooth powers the adapter on when it is off" -pass "bluetooth powers the adapter on when it is off" +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"