Proof Portal

Project overview

Omarchy

ProbeLabs65 findings · 95 requirements

A proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.

Inbox
ReportR-4861GitHub issuePublicReceived

Bar's Bluetooth widget can't be turned back on after off, when rfkill cuts radio power (ThinkPad ACPI)

Reported by oogala-boogala · filed Sep 3, 2026Closed upstream on 2026-09-20 by bjarneo via an automated message, as a duplicate of #6956 (R-4603). GitHub close reason: completed.

Received and recorded verbatim. Nothing has been checked yet.

At a glance

1 · What is claimed?
Only the report text below — no validator has restated it as checkable claims yet.
2 · What does it affect?
Not yet mapped to any requirement or component.
3 · Is it reproducible?
No validation has been run yet.
4 · What remains unclear?
  • Not validated yet: no claims, no reproducer.
5 · What happens next?
Our staff decide whether the report is valid. Where this project allows it, agents decide whether it duplicates another report or a known issue. No decision has been recorded yet.

Original report

Bar's Bluetooth widget can't be turned back on after off, when rfkill cuts radio power (ThinkPad ACPI)revision 1 · captured 2026-10-04 12:10 UTC via poll

System details

Lenovo ThinkPad (product 4282AD4), Omarchy 4.0.2-1, quickshell 0.3.1-1, kernel 7.1.9-arch1-2. Bluetooth radio: Broadcom, USB (0a5c:217f), driver btusb, on USB port 2-1.4.

What's wrong?

On hardware where the Bluetooth rfkill switch cuts power to the radio (rather than a plain soft-block), turning Bluetooth off from the bar leaves it permanently un-recoverable from the UI: the bar icon disappears entirely and there's no way to turn it back on except via the CLI (rfkill unblock bluetooth).

Steps to reproduce

  1. Turn Bluetooth off via the bar's Bluetooth widget (or omarchy bluetooth power off).
  2. Observe: the Bluetooth icon disappears entirely from the bar.
  3. Try to turn it back on — there's no icon/switch left to click.
  4. Only workaround: rfkill unblock bluetooth from a terminal, then wait.

Root cause

On this hardware, thinkpad_acpi's rfkill handler for Bluetooth doesn't just soft-block the radio — it cuts ACPI power to the USB port the BT chip sits on, and the device fully disconnects from the USB bus:

kernel: usb 2-1.4: USB disconnect, device number 3
kernel: Bluetooth: hci0: Opcode 0x0402 failed: -19
kernel: Bluetooth: hci0: Error when powering off device on rfkill (-19)

Because the USB device is gone, BlueZ deletes the hci0 adapter object from D-Bus entirely (not just Powered: no). Turning it back on requires the port to re-power and the device to re-enumerate on USB, which we measured taking 25-51s across repeated tests.

plugins/panels/bluetooth/Panel.qml assumes a live BlueZ adapter object is always available whenever Bluetooth hardware exists on the machine, and uses adapter !== null as the signal for "does this machine have Bluetooth" for all of:

  • the whole widget's visibility (visible: adapter !== null — the icon itself disappears)
  • the power switch's visibility (visible: !!root.adapter)
  • toggleBluetooth()'s guard clause (if (!adapter) return)

That assumption holds on hardware that keeps the adapter object around with Powered: no during a soft rfkill block, but not on hardware like this where the adapter object is destroyed while off. The result: once you turn it off, there's no surviving UI affordance to turn it back on, and even IPC's toggleBluetooth() is a no-op in that state.

Separately, bin/omarchy-bluetooth-power's default POWER_WAIT_SECONDS=2 is far shorter than this hardware's real recovery time (~25-51s), though this doesn't affect the bar (which doesn't check the script's exit status), only direct CLI/script callers.

Proposed fix (tested working)

Use rfkill list bluetooth as a fallback signal for "Bluetooth hardware exists on this machine," independent of whether BlueZ currently has a live adapter object for it. The rfkill platform switch (tpacpi_bluetooth_sw here) persists across the whole off/on cycle even when hci0 disappears, so it's a reliable way to distinguish "hardware temporarily gone mid-power-cycle" from "no Bluetooth hardware on this machine at all."

Verified end-to-end via qs ipc call omarchy.bluetooth toggleBluetooth (the same call the bar switch makes): toggling off still disconnects the USB device as before, but the widget now stays visible and correctly shows "Turned Off"; toggling on again while the adapter is still absent now correctly runs omarchy-bluetooth-power on (previously a no-op), and the radio reliably comes back ~25s later with no manual intervention.

plugins/panels/bluetooth/Panel.qml diff
--- a/plugins/panels/bluetooth/Panel.qml
+++ b/plugins/panels/bluetooth/Panel.qml
@@ -23,6 +23,30 @@
 
   readonly property var adapter: Bluetooth.defaultAdapter
 
+  // BlueZ deletes its adapter object entirely on some hardware (this radio's
+  // rfkill block cuts ACPI power to the USB port instead of just soft-blocking
+  // it) rather than keeping it around with Powered: no like most controllers.
+  // `adapter` alone can't tell "no Bluetooth hardware on this machine" apart
+  // from "hardware exists but is between power cycles" on that kind of setup,
+  // so rfkill — which still lists the platform switch even with no live
+  // adapter — is consulted as a fallback whenever the adapter disappears.
+  property bool rfkillHardwarePresent: false
+  readonly property bool hasHardware: adapter !== null || rfkillHardwarePresent
+  readonly property bool poweredOn: !!adapter && adapter.enabled
+
+  Process {
+    id: rfkillProbe
+    command: ["rfkill", "list", "bluetooth"]
+    stdout: StdioCollector {
+      id: rfkillProbeStdout
+      waitForEnd: true
+    }
+    onExited: root.rfkillHardwarePresent = (rfkillProbeStdout.text || "").trim() !== ""
+  }
+
+  Component.onCompleted: rfkillProbe.running = true
+  onAdapterChanged: if (!adapter) rfkillProbe.running = true
+
   // 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
@@ -56,8 +80,8 @@
   readonly property var discoveredDevices: deviceGroups.discovered || []
 
   readonly property string icon: {
-    if (!adapter) return ""
-    if (!adapter.enabled) return "󰂲"
+    if (!hasHardware) return ""
+    if (!poweredOn) return "󰂲"
     if (connectedDevices.length > 0) return "󰂱"
     return "󰂯"
   }
@@ -73,10 +97,10 @@
     "Wrangling codecs",
     "Polishing packets"
   ]
-  readonly property bool rotatingPhrases: adapter && adapter.enabled
+  readonly property bool rotatingPhrases: poweredOn
   readonly property string heroStatusText: {
-    if (!adapter) return "No adapter"
-    if (!adapter.enabled) return "Turned Off"
+    if (!hasHardware) return "No adapter"
+    if (!poweredOn) return "Turned Off"
     return activePhrases[phraseIndex % activePhrases.length]
   }
 
@@ -101,7 +125,7 @@
   // sits above the device sections so the adapter can be toggled by keyboard
   // even when it is off and no device rows exist.
   readonly property bool headerHasCursor: cursorActive && focusSection === "header"
-  readonly property string toggleHint: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on"
+  readonly property string toggleHint: root.poweredOn ? "Turn Bluetooth off" : "Turn Bluetooth on"
 
   readonly property color hoverFill: bar
     ? Style.hoverFillFor(bar.foreground, Color.accent)
@@ -497,7 +521,7 @@
     if (selectedIndex < 0) selectedIndex = 0
   }
 
-  visible: adapter !== null
+  visible: hasHardware
   implicitWidth: button.implicitWidth
   implicitHeight: button.implicitHeight
 
@@ -632,9 +656,13 @@
   // 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.
+  //
+  // Guarding on hasHardware rather than adapter: on hardware whose rfkill block
+  // cuts power to the radio, adapter is null for the whole time it's off, so a
+  // plain adapter check would leave this dead exactly when "on" is needed most.
   function toggleBluetooth() {
-    if (!adapter) return
-    Quickshell.execDetached(["omarchy-bluetooth-power", adapter.enabled ? "off" : "on"])
+    if (!hasHardware) return
+    Quickshell.execDetached(["omarchy-bluetooth-power", poweredOn ? "off" : "on"])
   }
 
   IpcHandler {
@@ -705,15 +733,15 @@
             color: root.bar.foreground
             font.family: root.bar.fontFamily
             font.pixelSize: Style.font.display
-            opacity: root.adapter && root.adapter.enabled ? 1.0 : 0.5
+            opacity: root.poweredOn ? 1.0 : 0.5
           }
 
           // Compact on/off switch on the trailing edge of the hero, and the
           // header's only cursor target.
           ToggleSwitch {
             id: powerSwitch
-            visible: !!root.adapter
-            checked: !!root.adapter && root.adapter.enabled
+            visible: root.hasHardware
+            checked: root.poweredOn
             hasCursor: root.headerHasCursor
             foreground: root.bar.foreground
             anchors.right: parent.right
@@ -867,8 +895,8 @@
         Text {
           textFormat: Text.PlainText
           visible: root.connectedDevices.length === 0 && root.scrollRows.length === 0
-          text: !root.adapter ? "No Bluetooth adapter"
-              : !root.adapter.enabled ? "Turn Bluetooth on to scan"
+          text: !root.hasHardware ? "No Bluetooth adapter"
+              : !root.poweredOn ? "Turn Bluetooth on to scan"
               : "Scanning for devices…"
           color: Qt.darker(root.bar.foreground, 1.5)
           font.family: root.bar.fontFamily
bin/omarchy-bluetooth-power diff
--- a/bin/omarchy-bluetooth-power
+++ b/bin/omarchy-bluetooth-power
@@ -16,7 +16,13 @@
 # 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}
+# 2s covers a plain soft-unblock. Some radios (e.g. rfkill switches that cut
+# ACPI power to the USB port instead of just blocking) need the device to
+# re-enumerate on USB after unblock, which has been observed to take upward of
+# 50s on affected hardware — hence the larger default. wait_powered still
+# returns the moment the adapter is up, so this only raises the failure
+# threshold and costs nothing on hardware that comes back quickly.
+POWER_WAIT_SECONDS=${OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS:-75}
 
 controllers() {
   timeout 2s bluetoothctl list 2>/dev/null | awk '{print $2}'

Happy to open this as a PR instead if that's the preferred path — just let me know.

View raw text
### System details

Lenovo ThinkPad (product `4282AD4`), Omarchy 4.0.2-1, quickshell 0.3.1-1, kernel 7.1.9-arch1-2. Bluetooth radio: Broadcom, USB (`0a5c:217f`), driver `btusb`, on USB port `2-1.4`.

### What's wrong?

On hardware where the Bluetooth rfkill switch cuts power to the radio (rather than a plain soft-block), turning Bluetooth off from the bar leaves it permanently un-recoverable from the UI: the bar icon disappears entirely and there's no way to turn it back on except via the CLI (`rfkill unblock bluetooth`).

**Steps to reproduce**

1. Turn Bluetooth off via the bar's Bluetooth widget (or `omarchy bluetooth power off`).
2. Observe: the Bluetooth icon disappears entirely from the bar.
3. Try to turn it back on — there's no icon/switch left to click.
4. Only workaround: `rfkill unblock bluetooth` from a terminal, then wait.

**Root cause**

On this hardware, `thinkpad_acpi`'s rfkill handler for Bluetooth doesn't just soft-block the radio — it cuts ACPI power to the USB port the BT chip sits on, and the device fully disconnects from the USB bus:

```
kernel: usb 2-1.4: USB disconnect, device number 3
kernel: Bluetooth: hci0: Opcode 0x0402 failed: -19
kernel: Bluetooth: hci0: Error when powering off device on rfkill (-19)
```

Because the USB device is gone, BlueZ deletes the `hci0` adapter object from D-Bus entirely (not just `Powered: no`). Turning it back on requires the port to re-power and the device to re-enumerate on USB, which we measured taking 25-51s across repeated tests.

`plugins/panels/bluetooth/Panel.qml` assumes a live BlueZ adapter object is always available whenever Bluetooth hardware exists on the machine, and uses `adapter !== null` as the signal for "does this machine have Bluetooth" for all of:

- the whole widget's visibility (`visible: adapter !== null` — the icon itself disappears)
- the power switch's visibility (`visible: !!root.adapter`)
- `toggleBluetooth()`'s guard clause (`if (!adapter) return`)

That assumption holds on hardware that keeps the adapter object around with `Powered: no` during a soft rfkill block, but not on hardware like this where the adapter object is destroyed while off. The result: once you turn it off, there's no surviving UI affordance to turn it back on, and even IPC's `toggleBluetooth()` is a no-op in that state.

Separately, `bin/omarchy-bluetooth-power`'s default `POWER_WAIT_SECONDS=2` is far shorter than this hardware's real recovery time (~25-51s), though this doesn't affect the bar (which doesn't check the script's exit status), only direct CLI/script callers.

**Proposed fix (tested working)**

Use `rfkill list bluetooth` as a fallback signal for "Bluetooth hardware exists on this machine," independent of whether BlueZ currently has a live adapter object for it. The rfkill platform switch (`tpacpi_bluetooth_sw` here) persists across the whole off/on cycle even when `hci0` disappears, so it's a reliable way to distinguish "hardware temporarily gone mid-power-cycle" from "no Bluetooth hardware on this machine at all."

Verified end-to-end via `qs ipc call omarchy.bluetooth toggleBluetooth` (the same call the bar switch makes): toggling off still disconnects the USB device as before, but the widget now stays visible and correctly shows "Turned Off"; toggling on again while the adapter is still absent now correctly runs `omarchy-bluetooth-power on` (previously a no-op), and the radio reliably comes back ~25s later with no manual intervention.

<details>
<summary><code>plugins/panels/bluetooth/Panel.qml</code> diff</summary>

```diff
--- a/plugins/panels/bluetooth/Panel.qml
+++ b/plugins/panels/bluetooth/Panel.qml
@@ -23,6 +23,30 @@
 
   readonly property var adapter: Bluetooth.defaultAdapter
 
+  // BlueZ deletes its adapter object entirely on some hardware (this radio's
+  // rfkill block cuts ACPI power to the USB port instead of just soft-blocking
+  // it) rather than keeping it around with Powered: no like most controllers.
+  // `adapter` alone can't tell "no Bluetooth hardware on this machine" apart
+  // from "hardware exists but is between power cycles" on that kind of setup,
+  // so rfkill — which still lists the platform switch even with no live
+  // adapter — is consulted as a fallback whenever the adapter disappears.
+  property bool rfkillHardwarePresent: false
+  readonly property bool hasHardware: adapter !== null || rfkillHardwarePresent
+  readonly property bool poweredOn: !!adapter && adapter.enabled
+
+  Process {
+    id: rfkillProbe
+    command: ["rfkill", "list", "bluetooth"]
+    stdout: StdioCollector {
+      id: rfkillProbeStdout
+      waitForEnd: true
+    }
+    onExited: root.rfkillHardwarePresent = (rfkillProbeStdout.text || "").trim() !== ""
+  }
+
+  Component.onCompleted: rfkillProbe.running = true
+  onAdapterChanged: if (!adapter) rfkillProbe.running = true
+
   // 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
@@ -56,8 +80,8 @@
   readonly property var discoveredDevices: deviceGroups.discovered || []
 
   readonly property string icon: {
-    if (!adapter) return ""
-    if (!adapter.enabled) return "󰂲"
+    if (!hasHardware) return ""
+    if (!poweredOn) return "󰂲"
     if (connectedDevices.length > 0) return "󰂱"
     return "󰂯"
   }
@@ -73,10 +97,10 @@
     "Wrangling codecs",
     "Polishing packets"
   ]
-  readonly property bool rotatingPhrases: adapter && adapter.enabled
+  readonly property bool rotatingPhrases: poweredOn
   readonly property string heroStatusText: {
-    if (!adapter) return "No adapter"
-    if (!adapter.enabled) return "Turned Off"
+    if (!hasHardware) return "No adapter"
+    if (!poweredOn) return "Turned Off"
     return activePhrases[phraseIndex % activePhrases.length]
   }
 
@@ -101,7 +125,7 @@
   // sits above the device sections so the adapter can be toggled by keyboard
   // even when it is off and no device rows exist.
   readonly property bool headerHasCursor: cursorActive && focusSection === "header"
-  readonly property string toggleHint: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on"
+  readonly property string toggleHint: root.poweredOn ? "Turn Bluetooth off" : "Turn Bluetooth on"
 
   readonly property color hoverFill: bar
     ? Style.hoverFillFor(bar.foreground, Color.accent)
@@ -497,7 +521,7 @@
     if (selectedIndex < 0) selectedIndex = 0
   }
 
-  visible: adapter !== null
+  visible: hasHardware
   implicitWidth: button.implicitWidth
   implicitHeight: button.implicitHeight
 
@@ -632,9 +656,13 @@
   // 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.
+  //
+  // Guarding on hasHardware rather than adapter: on hardware whose rfkill block
+  // cuts power to the radio, adapter is null for the whole time it's off, so a
+  // plain adapter check would leave this dead exactly when "on" is needed most.
   function toggleBluetooth() {
-    if (!adapter) return
-    Quickshell.execDetached(["omarchy-bluetooth-power", adapter.enabled ? "off" : "on"])
+    if (!hasHardware) return
+    Quickshell.execDetached(["omarchy-bluetooth-power", poweredOn ? "off" : "on"])
   }
 
   IpcHandler {
@@ -705,15 +733,15 @@
             color: root.bar.foreground
             font.family: root.bar.fontFamily
             font.pixelSize: Style.font.display
-            opacity: root.adapter && root.adapter.enabled ? 1.0 : 0.5
+            opacity: root.poweredOn ? 1.0 : 0.5
           }
 
           // Compact on/off switch on the trailing edge of the hero, and the
           // header's only cursor target.
           ToggleSwitch {
             id: powerSwitch
-            visible: !!root.adapter
-            checked: !!root.adapter && root.adapter.enabled
+            visible: root.hasHardware
+            checked: root.poweredOn
             hasCursor: root.headerHasCursor
             foreground: root.bar.foreground
             anchors.right: parent.right
@@ -867,8 +895,8 @@
         Text {
           textFormat: Text.PlainText
           visible: root.connectedDevices.length === 0 && root.scrollRows.length === 0
-          text: !root.adapter ? "No Bluetooth adapter"
-              : !root.adapter.enabled ? "Turn Bluetooth on to scan"
+          text: !root.hasHardware ? "No Bluetooth adapter"
+              : !root.poweredOn ? "Turn Bluetooth on to scan"
               : "Scanning for devices…"
           color: Qt.darker(root.bar.foreground, 1.5)
           font.family: root.bar.fontFamily
```

</details>

<details>
<summary><code>bin/omarchy-bluetooth-power</code> diff</summary>

```diff
--- a/bin/omarchy-bluetooth-power
+++ b/bin/omarchy-bluetooth-power
@@ -16,7 +16,13 @@
 # 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}
+# 2s covers a plain soft-unblock. Some radios (e.g. rfkill switches that cut
+# ACPI power to the USB port instead of just blocking) need the device to
+# re-enumerate on USB after unblock, which has been observed to take upward of
+# 50s on affected hardware — hence the larger default. wait_powered still
+# returns the moment the adapter is up, so this only raises the failure
+# threshold and costs nothing on hardware that comes back quickly.
+POWER_WAIT_SECONDS=${OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS:-75}
 
 controllers() {
   timeout 2s bluetoothctl list 2>/dev/null | awk '{print $2}'
```

</details>

Happy to open this as a PR instead if that's the preferred path — just let me know.

Validation

Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.

Linked

Not linked to any known issue, other report or change yet.

Decision

Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.

No decision has been recorded yet.