Fedora 43 → 44 broke my Hyprland, and I had to rebuild hy3 in shell

A few weeks after the Sway-to-Hyprland post, Fedora 44 dropped. I upgraded the laptop. Half the desktop fell over. This is the log of putting it back together — including realising I had to write my own mini-hy3 in five small shell scripts.

A little background first. I have moved from I3 to Sway to Hyprland, mostly for different technical reasons. Sway had better Hidpi support than I3, Hyprland had better a better portal implementation than Sway. Thats about it. My general usage is not advanced and I like the I3 model and want to keep it.

The upgrade itself

dnf system-upgrade download --releasever=44 refused to resolve. Conflict on libdisplay-info.so.2 vs .so.3, with the solopasha/hyprland COPR demanding the old soname:

1package hyprland-0.51.1-3.fc44.x86_64 from copr...
2  requires aquamarine(x86-64) >= 0.9.2
3package aquamarine-0.9.5-2.fc44.x86_64 from copr...
4  requires libdisplay-info.so.2()(64bit), but none of the providers
5  can be installed
6cannot install both libdisplay-info-0.3.0-1.fc44 and
7  libdisplay-info-0.2.0-4.fc43

The COPR I'd used to install Hyprland on F43 hadn't been rebuilt against F44's libdisplay-info 0.3. --allowerasing was thinking about it for ten minutes. Easier to remove the COPR's packages first, do the upgrade clean, then sort Hyprland out on F44:

1sudo dnf copr disable solopasha/hyprland
2sudo dnf remove aquamarine\* hypr\* xdg-desktop-portal-hyprland
3sudo dnf system-upgrade download --releasever=44 -y
4sudo dnf system-upgrade reboot

Reboot to TTY (no compositor), reinstall Hyprland — except now I had to pick a COPR. The old one (solopasha) was still stuck on Hyprland 0.51 for F44, which is months behind. After some trial and error I landed on ashbuk/Hyprland-Fedora, which has 0.55.1 from a few days prior, and uses vendored libraries in /usr/libexec/hyprland/vendor/ with RPATH isolation. No ABI conflicts with system packages, no libdisplay-info dance.

1sudo dnf copr enable ashbuk/Hyprland-Fedora
2sudo dnf install hyprland xdg-desktop-portal-hyprland

The COPR's username is lowercase ashbuk even though the GitHub repo is AshBuk/Hyprland-Fedora. The wrong case fails silently — the COPR just doesn't get added. Took me a minute to spot.

The catch with vendored builds: no plugins

Hyprland 0.55 comes up clean. But hyprpm update fails:

1-- Checking for module 'aquamarine>=0.9.3'
2--   Package 'aquamarine' not found
3CMake Error: The following required packages were not found:
4   - aquamarine>=0.9.3

The vendored COPR ships only the runtime .so files. No headers, no .pc files. So plugins can't compile against it. Including hy3, which is the whole reason I cared about Hyprland in the first place.

The options were:

  1. Switch back to solopasha + manually install libdisplay-info 0.2 as a compatibility shim. Works, but downgrades Hyprland to 0.51 and relies on a hand-pasted .so in /usr/lib64/.

  2. Build Hyprland from source so the headers are there. Then I'm a distro maintainer.

  3. Ride out the new Hyprland version without hy3. Replace the bits I actually used with shell.

Option 3 won. I'm not an advanced sway user. I used four hy3 features in practice. The rest of hy3 (autotiling, swallowing, deep tree manipulation, locked tabs) I never touched.

Hyprland 0.55 broke the config syntax too

While the upgrade was rebuilding, hyprctl configerrors scrolled across the screen. Two batches of breakage:

windowrulev2 is gone

Hyprland 0.55 removed windowrulev2. The replacement is windowrule with space-separated rule and matchers, not commas:

1# old (0.54)
2windowrulev2 = float, class:^(pavucontrol)$
3windowrulev2 = workspace 2 silent, class:^(firefox)$, title:^(\[WS-2\] .*)
4
5# new (0.55)
6windowrule = float class:^(pavucontrol)$
7windowrule = workspace 2 silent class:^(firefox)$ title:^(\[WS-2\] .*)

The error message is "invalid field float: missing a value" because the parser is trying to interpret float as a parameter that needs arguments before the comma. Confusing if you don't know what's changed.

Layout dispatchers moved behind layoutmsg

togglesplit used to be a top-level dispatcher. In 0.55 it's a "layout message", invoked through the layoutmsg wrapper:

1# old
2bind = $mod, B, togglesplit
3# new
4bind = $mod, B, layoutmsg, togglesplit

Same for swapsplit, preselect, movetoroot — anything layout-specific. The lua refactor in 0.55 cleaned up the dispatcher namespace; the cost is that any config older than two months won't load until you find what got renamed.

Tang/Clevis was wishful thinking on a laptop

While I was at it I tried setting up Tang auto-unlock so I wouldn't have to type the LUKS passphrase on boot. Short version: it doesn't work on Wi-Fi-only laptops, because the initramfs can't bring Wi-Fi up. Separate post coming on this one.

What I rebuilt of hy3, in five shell scripts

This is the meat. The hy3 features I used were:

  • super+W → tab everything in this workspace (sway's layout tabbed)
  • super+arrow → move focus or cycle through tabs if in a group
  • super+shift+arrow → move window or reorder tabs in a group
  • super+shift+N → send focused window to workspace N. If the window is in a group, take only that window, leave the others tabbed.
  • A windowrule that auto-routes Firefox windows by their [WS-N] title prefix.

Stock Hyprland gets you halfway. Its native group/tab system exists, but a group acts as a single layout-unit. Move "the window" and the whole group migrates with it. The other gap: movefocus l tries to walk out of the group, instead of cycling tabs inside it.

So I wrote a small set of shell scripts that wrap Hyprland's dispatchers with group-aware logic. They live in ~/roles/build_myprojs/hyprland which gets synced to all my machines.

tab-workspace: tab everything in the workspace

Stock togglegroup only adds the focused window to a 1-tab group. Sway's layout tabbed puts everything in the workspace into one tab-stack. The workaround is to walk every tiled window in the active workspace and moveintogroup them one by one, then focus the one you started on.

The trick is doing it in a single hyprctl --batch call. Otherwise each individual dispatch races with focus changes mid-script, and you miss windows in deep dwindle trees.

 1# scripts/tab-workspace
 2ws_id=$(hyprctl activeworkspace -j | jq -r '.id')
 3windows=$(hyprctl clients -j |
 4    jq -r ".[] | select(.workspace.id == $ws_id) | .address")
 5active_addr=$(hyprctl activewindow -j | jq -r '.address')
 6
 7cmds="dispatch togglegroup"
 8for pass in 1 2; do
 9    while IFS= read -r addr; do
10        cmds+=" ; dispatch focuswindow address:$addr"
11        cmds+=" ; dispatch moveintogroup l"
12        cmds+=" ; dispatch moveintogroup r"
13        cmds+=" ; dispatch moveintogroup u"
14        cmds+=" ; dispatch moveintogroup d"
15    done <<< "$windows"
16done
17cmds+=" ; dispatch focuswindow address:$active_addr"
18hyprctl --batch "$cmds"

The two passes handle dwindle's binary tree: a deeply-nested window might not be reachable in one call. Cribbed this from hyprtabs, a Go tool that does the same thing. The original is a couple of hundred lines in Go; the bash version is a third the size and easier to tweak.

Bound to super+W. Toggling: when called on an already-tabbed group, the same script ungroups everything back to tiles. Not shown above for brevity.

group-movefocus: focus that knows about tabs

 1# scripts/group-movefocus
 2dir="$1"
 3grouped=$(hyprctl activewindow -j | jq -r '.grouped | length')
 4if [[ "$grouped" -gt 1 ]]; then
 5    case "$dir" in
 6        l) hyprctl dispatch changegroupactive b ;;
 7        r) hyprctl dispatch changegroupactive f ;;
 8        *) hyprctl dispatch movefocus "$dir" ;;
 9    esac
10else
11    hyprctl dispatch movefocus "$dir"
12fi

If the focused window is in a group, left~/~right cycle tabs (using Hyprland's native changegroupactive). Otherwise normal directional focus. up~/~down always do normal focus, since vertical movement within a tab bar doesn't make sense.

A symmetric group-swapwindow wraps swapwindow vs Hyprland's movegroupwindow for super+shift+arrow. (Reorders tabs in the group when grouped, swaps with neighbor when not.)

move-to-ws: pop out of group before moving

1# scripts/move-to-ws
2target="$1"
3grouped=$(hyprctl activewindow -j | jq -r '.grouped | length')
4if [[ "$grouped" -gt 0 ]]; then
5    hyprctl --batch "dispatch moveoutofgroup ; dispatch movetoworkspace $target"
6else
7    hyprctl dispatch movetoworkspace "$target"
8fi

Bound to super+shift+N. If you're inside a tab group and you press super+shift+5, the focused tab pops out and goes to workspace 5, the other tabs stay grouped on the original workspace. This is the sway behavior I missed most.

ws-title-router: a daemon for window title rules

This one's not a hy3 feature, but it's a thing the windowrule workspace N silent, class:firefox, title:[WS-N] used to handle. Except — windowrules only fire at window creation, before the Window Titler extension applies the prefix. So the rule never matches.

Solution: listen to Hyprland's IPC event stream and react to title changes:

 1# scripts/ws-title-router (excerpt)
 2SOCK="${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket2.sock"
 3socat -U - UNIX-CONNECT:"$SOCK" | while IFS='>>' read -r line; do
 4    case "$line" in
 5        windowtitlev2*)
 6            payload="${line#windowtitlev2>>}"
 7            addr="${payload%%,*}"
 8            title="${payload#*,}"
 9            if [[ "$title" =~ ^\[WS-([0-9]+)\] ]]; then
10                target_ws="${BASH_REMATCH[1]}"
11                # ... query class, current ws, group state, then move
12            fi
13            ;;
14    esac
15done

Hyprland exposes a Unix socket at $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock that streams events one per line. windowtitlev2 events fire on every title change with the window's address. Filter for Firefox + [WS-N] prefix, and movetoworkspacesilent as needed (popping out of the group first if the window is currently grouped).

Started via exec-once in hyprland.conf. Survives across the session.

All of it together

The end state: Hyprland 0.55.1 from the AshBuk COPR, no plugins, five small bash scripts (tab-workspace, group-movefocus, group-swapwindow, move-to-ws, ws-title-router) covering the hy3 features I actually used. The whole thing is git-managed so the same config syncs to my other machines.

It's about 200 lines of bash total. hy3 is a 4000-line C++ plugin. For my use case the trade-off is fine: I lost the deep tree manipulation I never used, gained scripts I can actually read in a minute when something breaks.

The takeaway, if there is one: when the ecosystem you're depending on isn't quite stable, sometimes the most resilient option is to depend on less of it. Hyprland's IPC and dispatchers have been stable across the upgrade chaos. The plugin ABI hasn't, and packaging is a moving target. Five bash scripts hitting an HTTP-ish socket are easier to keep alive than a binary plugin tied to a specific compositor commit.

Now let's see if any of this still works after Fedora 45.