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>
* 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>
* fix(network): drop the redundant rescan on the bar click
Opening from the bar ran open() and then a bare refresh(). open() already
triggers onOpenedChanged -> refresh(true), which defers the PHY scan by
disabling the scanner and re-enabling it from scanRestart. The bare
refresh() that followed defaults scanWifi to false, so it took the other
branch and set wifiDevice.scannerEnabled synchronously on the click frame,
undoing the deferral and stalling the open on NetworkManager's access-point
flood. It also double-started the DNS and band probes.
Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(network): keep wifi rows QObject-free to prevent a delegate crash
wifiRow() embedded the WifiNetwork QObject in the row it returns, and those
rows are list-model data, so every delegate held a live QObject wrapper in a
var property. When NetworkManager churns the list -- a scan's access-point
flood, an AP disappearing -- the object can be destroyed while a delegate is
still incubating, and quickshell segfaults in QObjectWrapper::wrap_slowPath
on the dangling wrapper.
Project primitives only and resolve the backend object at action time via
the existing networkForSsid(). Both failNetworkAction() and
checkActionCompletion() already no-op on a null network, so a row whose
network has since vanished is handled the same way it was before.
Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(bluetooth): keep device rows QObject-free to prevent a delegate crash
Same crash class as the wifi rows: scrollRows embedded the BlueZ Device
QObject in list-model data, so every delegate held a live wrapper in a var
property. Discovery churn -- a scan timeout dropping a device, an unpair --
can destroy the object while a delegate is still incubating, and quickshell
segfaults on the dangling wrapper.
Project primitives for both the scroll rows and the connected rows, and
resolve the backend object by address in deviceFor() for the click actions.
The keyboard flow already went through deviceAt(), which reads the live
device arrays directly rather than model data, so it is untouched.
Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(network): guard row disconnects against a vanished network
Row activation resolved the WifiNetwork with networkForSsid() and passed the
result straight to disconnect(), which falls back to connectedWifiNetwork
when handed null. A row is a primitive snapshot, so scan churn can remove its
backing object while the row is still on screen -- activating it then tore
down whatever happened to be connected at that moment rather than doing
nothing.
Route both row paths through disconnectRow(), which resolves first and only
acts when the row still maps to a live network. disconnect() keeps its
fallback for callers that mean "drop the current connection".
Also covers the bar-click open path, which had no regression: the suite
already asserts against Panel.qml source, so assert the closed branch calls
open() alone and never a second refresh().
Co-Authored-By: shrijit <shrijitsrivastav@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: shrijit <shrijitsrivastav@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every toggle you could only reach by mouse or panel hotkey is now
scriptable through omarchy-shell:
- omarchy.bluetooth toggleBluetooth — the radio (was right-click / B)
- omarchy.network toggleNetwork — Wi-Fi on/off (was W in the panel)
- omarchy.tailscale toggleTailscale — the connection as one call,
matching the widget's right-click, instead of separate up/down
- omarchy.clock toggleWeekStart — calendar week start (was W)
- shell toggleBarTransparency — the bar background (was manage UI only)
Bluetooth and network follow the power/monitor pattern: manageIpc: false
so the panel owns the target's single IpcHandler and can extend it past
the inherited lifecycle methods.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The panel drove a Flickable by hand: two Repeaters in a Column, plus an
ensureCursorVisible() that mapped each row into content coordinates and
moved contentY itself. That is the machinery a ListView already has, and
the hand-rolled version came with a 6px margin that scrolled the list
whenever the mouse merely rested near the viewport edge.
Flatten the remembered and discovered groups into one model whose entries
carry the section they came from, and let the view keep the current row
visible. Section headers are computed per index the way the network panel
does it. The cursor keeps working in section-relative terms, so
activation, forget, hover, and the hero toggle are untouched.
Positioning has to be deferred a turn: called straight out of
onCurrentIndexChanged it silently does nothing, because the model is
rebuilt every time discovery reports and the swap resets the view out
from under the call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add Tailscale header on/off toggle
* Address Tailscale toggle review feedback
* Hide Tailscale tooltip while busy
* Keep Tailscale labels clear of header toggle
* Extract the switch from Toggle into a reusable ToggleSwitch
Toggle rendered its own track and knob inline, so anything else wanting a
switch had to copy the geometry. Pull it into Ui/ToggleSwitch.qml and let
Toggle compose it, and give PanelHero a trailingControl slot so a hero can
pin a control to its trailing edge without the caller doing the layout.
The switch draws its cursor as a ring outside the track: themes give normal
chrome a stronger border than hover-cursor, which is right for controls that
are borderless at rest but would make a bordered track go fainter under the
cursor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep the panel cursor on the virtual header section
clampCursor resets focusSection whenever it is not in visibleSections, but
"header" is virtual and never appears there. Any refresh of the underlying
model therefore threw the cursor off the hero toggle: muting republishes the
PipeWire snapshot, and toggling the Bluetooth adapter empties and refills the
device lists. moveCursor already special-cases "header"; clampCursor now does
too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Toggle panel heroes with a switch instead of the status icon
The hero icon doubled as the on/off control, which was invisible as an
affordance and made the icon carry two jobs at once. Give Tailscale, Dropbox,
Bluetooth, Audio, and Network a ToggleSwitch on the trailing edge of the hero
and leave the icon to report status. The switch is the header's only cursor
target, so the keyboard reaches it the same way the mouse does.
Dropping the icon's focus ring also drops heroRingPad, which lets each hero
line up with the rows beneath it. Network's link detail moves inline after the
name -- "Ethernet (2.5gbit)" -- since the pill crowded the switch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Make the network hero switch honestly a Wi-Fi switch
The switch reads and writes Networking.wifiEnabled, but its tooltip claimed to
turn "network" on and off whenever Ethernet was the active connection. A click
asserted nothing; a switch asserts state, so on a wired machine with the radio
off it sat there reading "off" beside a perfectly live Ethernet connection.
Say Wi-Fi, and only offer the switch when there is a radio to switch.
headerActionCount follows the same condition so the keyboard cannot reach a
control that is not there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop the header cursor claiming an absent switch
The switch hides when the thing it toggles is unavailable -- no Tailscale CLI,
no Dropbox CLI, no Wi-Fi radio -- but "header" stayed reachable, so the cursor
could sit on a target that never rendered. The old clickable icon was always on
screen, so there was always something to highlight.
"header" stays navigable and Enter still no-ops safely; the cursor just stops
claiming a spot that is not there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Wrap the gallery's switch caption
The caption sat unbounded inside the switch row. The gallery has a 560px
minimum width, horizontal scrolling off, and clipping on, so at that size the
end of the line was simply unreachable. Move it below the row and wrap it, the
way every other description in the gallery already does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each panel's hero on/off toggle (Tailscale, Dropbox Wi-Fi, Bluetooth,
network, audio mute) now has a reachable "header" cursor section, a focus
ring around the hero icon, Enter/Space activation, a letter shortcut, and
hover parity. The hero is inset by heroRingPad so the ring stays inside
the Flickable/ScrollView clip box instead of being cut off.
Bluetooth and audio get a virtual "header" section above their device
sections, so the adapter/mute can be toggled by keyboard even when no
device rows exist. Network repurposes its previously dead header section.
For the PanelHero-based panels (Tailscale, Dropbox), route panel state
through the wrapper's `header` id: inside a PanelHero iconComponent `root`
resolves to PanelHero, not the Panel, so `root.headerHasCursor` and
`root.setHeaderCursor()` silently referenced the wrong object.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>