Hyprland's `windowrule = workspace 4, class:^(vivaldi)$` has no COSMIC
counterpart, and the missing piece is small: map_window already accepts a
workspace other than the active one, because activation tokens use that
path. This adds a second source for the same decision.
`window_rules` is a new CosmicCompConfig key rather than an addition to
com.system76.CosmicSettings.WindowRules. That component belongs to
cosmic-settings-daemon and holds one kind of rule -- tiling exceptions,
`{ appid, title }` with no action, because the action is implied. There is
nowhere in it to say *which workspace*, and putting one there would mean
forking a second upstream crate to add a field cosmic-settings' own UI
could not show. `preserve_split` set the precedent for keys the fork adds.
The matching half is deliberately the same shape as the exceptions: two
RegexSets, app ids and titles, a rule matching when the same index matches
in both. A regex that works in a tiling exception works here. The third
vector is what an exception list does not need -- exceptions answer yes or
no, a rule has to say where -- and it is index-aligned with the two sets,
which is why an invalid expression is skipped from all three together.
Precedence
An activation token wins. It is an application saying where it wants this
window right now -- a file manager reopening a document where you launched
it from -- and that outranks a rule written months ago. So the rules are
consulted only when nothing else claimed a workspace.
`was_activated` stays keyed to the token rather than to the resulting
handle. It raises the urgency hint on the target workspace, and a window
that went exactly where the config said is not worth flagging; one that
turned up somewhere unexpected is. Without splitting the two apart, every
rule-placed window would have made its workspace blink.
That also means a rule never switches you to the workspace it used, which
is Hyprland's `silent` and is now unconditional.
Missing targets miss
Workspaces are resolved per output, because a WorkspaceSet is, so
"workspace 4" is the fourth workspace of the screen the window would have
opened on. A rule naming a workspace that does not exist returns None and
the window opens where it would have anyway: `workspace 4` with three
workspaces must not conjure a fourth, and a name only exists because a
`workspace` line created it.
Live
The key has an arm in the config watcher, so an edit applies to the next
window that opens. Nothing moves -- windows already open stay put, since
the rules are only read at map time. That is the opposite of
pinned_workspaces, which is read once at startup and lands at next login.
Not verified: nothing was compiled here. This half is built by
packages.yml; the cosmic-conf job covers the parser that writes the key.
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.