HyprCosmic is a fork of COSMIC, not an add-on to it, so cosmic-comp goes
where a cosmic-comp goes: $(bindir)/cosmic-comp.
This reverses the private $(libexecdir)/hyprcosmic layout. That bought a
fallback -- both compositors on disk, both sessions bootable -- and cost
coherence: a session that had to name absolute paths to find its own
binary, and no answer to "which cosmic-comp is running". The fallback
survives in a better form, because cosmic-session installs cosmic.desktop
as well as hyprcosmic.desktop and both are now served by this binary.
The consequence is stated rather than hidden: a package built from this
conflicts with the distribution's cosmic-comp over /usr/bin/cosmic-comp.
That is the honest relationship between two builds of the same program.
libexecdir is dropped from the Makefile, having been added by this fork
and used by nothing else. CI asserts the binary and both .ron defaults
land at upstream's paths, and that nothing returns to the libexec
directory -- a stray copy there is a second compositor that nothing runs
and no uninstall removes.
Additive to upstream ci.yml, which is left exactly as it is. That workflow asks
whether the code is formatted, lints clean and still compiles under each feature
flag, on one Ubuntu runner. None of that stopped being worth asking when the
repository was forked, and replacing it would put a conflict in the path of
every rebase against pop-os/cosmic-comp for no gain.
What it does not ask is whether a distribution has a package called libseat-dev.
That question cannot be put to a single Ubuntu runner, so this builds in three
containers with three package managers and three sets of names -- a job failing
here means a real user of that distribution could not have built it either.
Rust comes from rustup because Debian bookworm ships rustc 1.63 and this crate
needs 1.93 and edition 2024; the packaged toolchain simply cannot build it on
one of the three targets. --default-toolchain none lets rust-toolchain.toml pick
the version, so CI cannot drift onto a compiler the project does not use.
The last step is the point of the whole thing. `make install` is staged into a
DESTDIR and then asserted over: the binary must be at libexec/hyprcosmic, and
/usr/bin/cosmic-comp must NOT exist. That is the rule that keeps installing this
fork from being a one-way door -- if it will not start, the stock session is
still on the greeter's menu to log in and fix it from. Until now that rule lived
only in a Makefile comment.
The binary went to $(bindir)/cosmic-comp, which is the distro's own
cosmic-comp. Installing this fork there replaces the compositor the stock
COSMIC session runs, and that makes installing HyprCosmic a one-way door: if
the fork fails to start, the session you would log into to fix it is now
running the same broken binary. It goes to $(libexecdir)/hyprcosmic instead,
which is where the hand-installed copy has been living all along -- this
teaches the Makefile the layout rather than inventing one.
Both sessions are meant to coexist. /usr/share/wayland-sessions holds
cosmic.desktop and hyprcosmic.desktop side by side, and the greeter offers
both; that only stays true while the two compositors occupy different paths.
The two .ron files move out of `install` for a related reason. They are
COSMIC's shared defaults, at paths the distro's cosmic-comp package already
owns, so a HyprCosmic package installing them would conflict with that package
over two files it has no reason to change -- and `uninstall` would have deleted
the stock session's keybindings on the way out. The fork reads the stock copies,
which costs nothing: a HyprCosmic session already needs the rest of COSMIC for
the greeter, the portals and the settings daemon. `install-defaults` keeps them
reachable for anyone building this fork without a COSMIC install to borrow from.
Verified by staging into a DESTDIR: the result is byte-identical to the copy
currently running from /usr/libexec/hyprcosmic/cosmic-comp.
COSMIC's tiling tree is already N-ary -- `Data::Group` holds a `sizes:
Vec<i32>` and `add_window` divides by `sizes.len() + 1`, so three panes in
a row is perfectly representable. You just cannot get there by opening
windows. `map_to_tree` always wraps the focused window in a *new* group
whose orientation comes from that window's aspect ratio, so the second
window splits the screen into two columns, and the third one -- opening
into a column that is now taller than wide -- splits that column
horizontally. Quadrants. The limit was the insertion rule, not the shape
of the tree.
`preserve_split` makes the new window join the group the focused window
is already in, at the index right after it, keeping that group's
direction. Three windows land as three panes in a row, which is what
Hyprland's dwindle layout does with the option of the same name.
The insert is `move_current_node`'s idiom, minus the move: children and
`sizes` are positional, so the node goes in at `idx + 1` and
`add_window(idx + 1)` puts the size at the matching slot. Get those out
of step and every window in the group draws one slot over.
Off by default, so an unconfigured build still lays out like COSMIC.
HyprCosmic turns it on in cosmic.conf. It only affects where the next
window is inserted, never existing windows, so the config watcher just
pushes the flag down through `update_config` -- there is nothing to
retile.
The sockets were created as `.socket` and `.socket2`. Hyprland's are
`.socket.sock` and `.socket2.sock`, and every real client hardcodes those
names: waybar's hyprland/* modules, hyprctl, eww, ags. None of them fall back
and none of them say much when they fail -- waybar reports "Couldn't connect
to ... (3)" once at startup and then disables the module, so the whole symptom
is a bar with a workspace widget that never appears. The IPC was correct and
unreachable, which is the worst of both.
With the names fixed the read side is reachable, but a workspace you can see
and cannot click is only half a bar. So: a `dispatch` verb, deliberately the
smallest one that makes the bar work. `dispatch workspace N` switches, and
everything else is refused.
That refusal is the point. This socket is the surface any process able to open
it can use to drive the compositor, so the parser rejects rather than guesses:
`dispatch exec` is not implemented and never will be here, `killactive` is
not implemented, and an unparsed request gets no answer rather than being
treated as something near it.
Which is how the test earned its keep. `dispatch workspace +1` means "one to
the right" in Hyprland, and it was coming through as absolute workspace 1 --
because `u32::from_str` accepts a leading `+`. On workspace 1 that looks like
it worked. The target is now checked for digits before it is parsed, so every
relative form is rejected instead of silently mistranslated.
Emits openwindow/closewindow/activewindow/workspace by diffing a state
snapshot from lib.rs's existing throttled refresh, rather than instrumenting
window map/unmap and focus-change sites across shell/. One call plus one new
file, so the fork stays rebasable against the ~46 upstream commits a month
those files see.
The snapshot walk is skipped entirely when nothing is subscribed to .socket2,
which is every tick when no bar is running. The baseline is still updated so
a newly-connected client does not receive a burst of openwindow events for
windows that were already open.
Ordering follows Hyprland: openwindow before the activewindow that usually
accompanies it, closewindow after, so a client tracking windows by address
never sees an activewindow for one it has not been told about.
Known trade-off: a window opening and closing inside one 150ms tick produces
no events. Hyprland would emit both.
9 new tests driving a real socketpair through the actual broadcast path.
22 hypr_ipc tests pass.
Runtime-verified nested: subscribing to .socket2 and opening a window yields
openwindow>>1,1,foot,foot / activewindow>>foot,foot
activewindow>>, / closewindow>>1
Binds .socket (request/response) and .socket2 (event stream) under
$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE, so waybar's
hyprland/workspaces and hyprland/window modules work unmodified. Both
listeners register on the existing calloop loop following session.rs's
run_socket pattern; nothing spawns a thread touching compositor state.
Wiring the agent documented but did not apply:
- lib.rs calls hypr_ipc::init before announcing readiness, so the signature
is exported in time for get_env to pass it to children. Failure is
non-fatal; only waybar's hyprland/* modules go dark.
- Common gains an Option<HyprIpcHandle>, None until the sockets bind.
- session.rs get_env forwards HYPRLAND_INSTANCE_SIGNATURE alongside
WAYLAND_DISPLAY, which is how waybar finds the socket.
cargo check passes clean; 13 hypr_ipc unit tests pass, covering the JSON
field names, event-line formats and command parsing. Full lib suite: 14
passed, 0 failed.
NOT runtime-verified — no Wayland session available here, so nothing confirms
waybar actually consumes these sockets.
Event notifications are inert until notify_* is called from focus-change
sites; request/response works without that.
Implements the wlr foreign-toplevel management protocol on top of the
existing ToplevelInfoState/ToplevelManagementHandler, so waybar's wlr/taskbar
module works. cosmic-comp previously shipped only ext-foreign-toplevel-list,
which enumerates but cannot activate or close.
The wlr manager state is constructed alongside ForeignToplevelListState and
driven from the same new_toplevel/remove_toplevel/refresh hooks, so no
separate registration in state.rs is needed.
Accessors global_id and registered_toplevels moved to an unbounded impl
block: adding the wlr dispatch bounds to the shared impl made them viral and
broke overlap_notify.rs, which needs neither.
cargo check passes clean. NOT runtime-verified — no Wayland session here.