Remember Bluetooth on/off through the rfkill soft block (#6682)

* Turn Bluetooth off with an rfkill soft block

BlueZ never persists an adapter's Powered property, so turning Bluetooth off in
the panel lasted only until the next boot. Omarchy's answer was AutoEnable=false,
which persists nothing either — it just means "never power the adapter on", so
Bluetooth came up off every boot whatever the user had chosen.

The soft block already does the job. systemd-rfkill saves every switch under
/var/lib/systemd/rfkill and restores it early on the next boot; that is the
entire purpose of the unit. Blocking also covers every controller at once, where
bluetoothctl only ever addresses the default one.

So the block becomes the state and BlueZ follows it: with AutoEnable back at its
stock default, lifting the block is enough for bluetoothd to power the adapter up
on its own. Powered still tracks the block, so the panel switch and icon read it
exactly as before. Everything that turns Bluetooth on or off goes through
omarchy-bluetooth-power, because bluetoothctl power on fails while a block is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Carry installed machines over to the rfkill block

Existing installs have AutoEnable=false, so their adapter is down at every boot
and Powered is the only record of what the user actually wants. Read it before
anything changes, hand it to the block, then put AutoEnable back to its default
so bluetoothd can act on that block. Only the exact line Omarchy wrote is
reverted, so a hand-edited opt-out survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Ask the power helper for a direction, not a toggle

The helper runs detached and the switch only moves once BlueZ catches up, so a
second click inside that window re-read the pre-click state and undid the first.
The panel already knows which way it wants to go, so let it say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Read every controller and bound the power-up wait

The block hits every Bluetooth radio at once, but the state was read from a bare
bluetoothctl show, which reports the default controller only. A powered dongle
sitting behind a powered-down internal controller read as off and got blocked
along with it. Enumerate the controllers and take any powered one as on, exposed
as is-on so callers do not each reinvent the read.

The wait counted probes rather than time, so a wedged D-Bus turned a two-second
bound into roughly fifty across a full power-up. One deadline around the whole
wait holds it near nine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Change the radio through sudo in the migration

/dev/rfkill is only writable unelevated from an active graphical seat, so an
update run over SSH failed here with EACCES. Migrations run under bash -e, so
that aborted before the config revert and the marker, and aborted again on every
retry. The privilege guidance already calls for sudo on machine-wide work run
from a visible terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-10 20:27:40 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 567e24cd90
commit c53190be07
7 changed files with 367 additions and 22 deletions
+3 -2
View File
@@ -20,8 +20,9 @@ address=${2:-}
power_on() { power_on() {
[[ $(timeout 2s bluetoothctl show 2>/dev/null) == *"Powered: yes"* ]] && return [[ $(timeout 2s bluetoothctl show 2>/dev/null) == *"Powered: yes"* ]] && return
bluetoothctl power on >/dev/null 2>&1 || true # Not bluetoothctl directly: Bluetooth is turned off by an rfkill soft block,
sleep 0.5 # and BlueZ refuses to power an adapter up while one is set.
omarchy-bluetooth-power on || true
} }
trust_device() { trust_device() {
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
# omarchy:summary=Turn Bluetooth on or off, remembered across reboots
# omarchy:group=bluetooth
# omarchy:args=<on|off|toggle|is-on>
# 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 <on|off|toggle|is-on>" >&2
exit 1
;;
esac
+5 -4
View File
@@ -1,6 +1,7 @@
systemctl enable bluetooth.service systemctl enable bluetooth.service
# Persist last power state across reboots (default AutoEnable=true overrides it) # AutoEnable stays at its stock default on purpose. It was set to false here to
if [[ -f /etc/bluetooth/main.conf ]]; then # persist the power state, which it never did: BlueZ has no such behaviour, so
sed -i 's/^#\?AutoEnable=.*/AutoEnable=false/' /etc/bluetooth/main.conf # all it bought was Bluetooth coming up off on every boot. omarchy-bluetooth-power
fi # 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.
+36
View File
@@ -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"
+9 -1
View File
@@ -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() { function toggleBluetooth() {
if (!adapter) return if (!adapter) return
adapter.enabled = !adapter.enabled Quickshell.execDetached(["omarchy-bluetooth-power", adapter.enabled ? "off" : "on"])
} }
IpcHandler { IpcHandler {
+109
View File
@@ -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"
+116 -15
View File
@@ -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(/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') 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.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') 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') assertEqual(bluetooth.normalizedAddress('AA:BB_CC-dd-ee-ff'), 'aabbccddeeff', 'bluetooth normalizes BlueZ and PipeWire address formats')
@@ -108,36 +112,109 @@ assert(
) )
JS JS
# The power-on shortcut is the whole point of skipping the stabilization sleep: # Turning Bluetooth off is an rfkill soft block, not a bluetoothctl power off,
# pair/connect from the panel run against an adapter that is already powered. # 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) device_tmp=$(mktemp -d)
trap 'rm -rf "$device_tmp"' EXIT trap 'rm -rf "$device_tmp"' EXIT
mock_bin="$device_tmp/bin" mock_bin="$device_tmp/bin"
mkdir -p "$mock_bin" mkdir -p "$mock_bin"
export POWERED_FILE="$device_tmp/powered"
cat >"$mock_bin/bluetoothctl" <<'SH' cat >"$mock_bin/bluetoothctl" <<'SH'
#!/bin/bash #!/bin/bash
printf '%s\n' "$*" >>"$BLUETOOTHCTL_LOG" 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 exit 0
SH 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 powered="$1"
local log="$device_tmp/$powered.log" shift
: >"$log" echo "$powered" >"$POWERED_FILE"
PATH="$mock_bin:$PATH" BLUETOOTHCTL_LOG="$log" BLUETOOTHCTL_POWERED="$powered" \ : >"$device_tmp/log"
"$ROOT/bin/omarchy-bluetooth-device" connect AA:BB:CC:DD:EE:FF || PATH="$mock_bin:$ROOT/bin:$PATH" BLUETOOTHCTL_LOG="$device_tmp/log" \
fail "omarchy-bluetooth-device exits cleanly with Powered: $powered" OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS=0 "$@" ||
printf '%s' "$log" 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) 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" 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" 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" fail "bluetooth still connects when the adapter is already powered"
pass "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) unpowered_log=$(bluetooth_device_log no)
grep -qx "power on" "$unpowered_log" || grep -qx "rfkill unblock bluetooth" "$unpowered_log" ||
fail "bluetooth powers the adapter on when it is off" fail "bluetooth lifts the block before connecting" "$(cat "$unpowered_log")"
pass "bluetooth powers the adapter on when it is off" 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"