Compare commits

...
58 Commits
Author SHA1 Message Date
gitops 7df465ef26 justfile: build the portal with just, because it has no Makefile
Run 31383454158, arch job. It compiled all 26 other components over two
hours and eighteen minutes and then failed on the last line of `build`:

    /usr/sbin/make -C xdg-desktop-portal-cosmic all
    make: *** No rule to make target 'all'.  Stop.

xdg-desktop-portal-cosmic has no Makefile. It has a justfile, with `build`
and `install` recipes, and the submodule is clean at the commit this fork
pins.

This is upstream's, not something introduced here. The line dates from
2022 (f4fb6a86), pop-os/cosmic-epoch master still carries it verbatim, and
upstream pins the same portal commit this fork does -- f211aa37,
epoch-1.5.0 -- so `just build` on upstream cosmic-epoch fails in exactly
the same place. The portal moved from a Makefile to a justfile and the
meta-repository's recipe was never updated. It goes unnoticed because
distributions build COSMIC one component at a time and never take this
path; it takes a full meta-build to reach the failure, and the failure is
on the very last line.

`build` rather than `build-release`: the portal has no build-release
recipe, and its `build` defaults to debug='0', which selects --release.
`install` takes rootdir/prefix rather than DESTDIR/prefix, being a
justfile.

Every other delegation in this justfile was checked the same way -- each
`make -C dir target` against that directory's Makefile targets, each
`just dir/recipe` against that directory's recipes, following justfile
imports -- and the portal was the only one that did not resolve.
2026-08-10 20:47:57 +07:00
gitops a397334242 packages: derive the build dependencies instead of assembling them by hand
Run 31383454158, debian job, eighteen minutes in:

    error: failed to run custom build command for `dav1d-sys v0.8.3`
    Package 'dav1d', required by 'virtual:world', not found

dav1d is pulled in by cosmic-bg, and nothing in the obvious set of
compositor dependencies names it. A hand-assembled list will keep having
that shape of hole, and each one costs a full run to find.

So the lists are now the union of Build-Depends across all 27 upstream
components' debian/control files, collected mechanically. That adds
libdav1d-dev, libglib2.0-dev, libspa-0.2-dev, libegl1-mesa-dev,
imagemagick, intltool and fonts-open-sans, none of which were present
before and all of which upstream declares.

The Fedora and Arch translations were verified against `dnf repoquery` and
archlinux.org's package API rather than guessed, because a name that does
not exist fails the whole step and costs another run to discover: it is
libdav1d-devel on Fedora and not dav1d-devel, and ttf-opensans on Arch and
not otf-opensans. Both mistakes were in the first draft of this commit.
2026-08-10 18:48:59 +07:00
gitops 4bedb04f77 packages: name the basic tools Fedora's image does not ship, and drop rust-cache
Run 31383454158, fedora job, first line of the build:

    justfile:3:  make := `which make`
    sh: line 1: which: command not found

Fedora's container image is deliberately minimal and has neither `which`
-- deprecated there in favour of `command -v` -- nor make. Arch's
base-devel and Debian's build-essential include both, which is why this
broke on exactly one of the three.

Rather than fix that one symptom and rediscover the next, the recipes were
read for what they actually shell out to: 360 cargo, 163 install, 114 tar,
26 which, 18 find, 11 sed, 4 xargs, 2 make. findutils and tar are also
absent from Fedora's image, so both would have failed in turn, one run at
a time. All of them are now named, along with gzip, sed and diffutils.

Debian gains `file`, used by the deb step to pick ELF binaries out of the
staged tree for dpkg-shlibdeps. It is not part of Debian's essential set.

rust-cache is removed rather than repaired. It runs `cargo metadata` at the
workspace root to decide what to cache, and this root is a meta-repository
with no Cargo.toml -- 29 submodules, each its own crate -- so it reported
"could not find Cargo.toml" and cached nothing. Listing all 29 workspaces
would fix the error and buy a worse problem: their target directories come
to roughly 17 GB against a 10 GB per-repository limit, so the three jobs
would evict each other every run and pay the upload time for it. A cold
build is the honest cost of a workflow that runs on tags and on demand.
2026-08-10 18:30:32 +07:00
gitops e65a9722d9 packages: give rustup a default, and run steps under bash
Both failures on run 31383204572, all three distributions, in the same
step, before anything was compiled.

--default-toolchain none was copied from the two fork workflows, where it
is correct: their checkout root is the crate, so rust-toolchain.toml sits
in it and rustup reads the pin. This repository's root has no pin -- it is
in cosmic-comp/, one level down, and the other 27 components have none at
all -- so `none` left no default and the first cargo invocation at the
root failed with "could not choose a version of cargo to run".

stable does not weaken the pin. rustup applies a directory-local
rust-toolchain.toml on entering that directory and installs it on demand,
so cosmic-comp still gets the 1.93 it asks for.

The same logs showed `shell: sh -e {0}`, which is the container default
and is dash on Debian. Two later steps use brace expansion, which dash
lacks and bash-as-sh disables, so the RPM build would have created a
directory literally named rpmbuild/{BUILD,RPMS,...} and failed further
along. Declared bash for the job rather than rewriting around a constraint
none of the three images impose.

Worth recording why this was not caught before pushing: the run blocks
were syntax-checked with bash, which is not what was going to run them.
Checking with dash would not have caught it either -- brace expansion
failing is runtime behaviour, not a parse error. Declaring the shell is
the fix; there is no static check that substitutes for it.
2026-08-10 18:25:36 +07:00
gitops d2b091fa8b Build installable packages in CI, and replace COSMIC rather than sit beside it
Bumps both forks to the commits that install at upstream's paths, and adds
the packaging that follows from it.

packaging/ holds one definition per distribution -- an RPM spec, a PKGBUILD
and a Debian control template -- and each wraps a tree that `just install`
has already staged rather than compiling again inside the packaging tool.
Building 27 Rust components a second time to produce bytes that already
exist costs hours and creates a way for the packaged desktop and the built
one to drift apart.

One package per distribution, not one per component. Fedora splits COSMIC
into 27 packages, which is right for a distribution tracking upstream. This
is a fork that replaces the desktop as a unit: there is no supported
combination in which you take the HyprCosmic cosmic-comp and the
distribution's cosmic-session, and one package says so accurately.

All three declare a conflict with the distribution's cosmic-comp and
cosmic-session, and stop rather than resolve it. Obsoletes would let a
routine install quietly remove the desktop the machine is currently
running; removing COSMIC stays a decision a person makes.

.github/workflows/packages.yml builds all three, each inside a container of
the distribution it targets, because nothing here is statically linked and
a package built elsewhere records sonames the installing machine will not
have. It runs on tags and on demand rather than on every push -- three full
desktop builds is hours of runner time for artifacts nobody downloads --
and a tag opens a draft release, not a published one.

The RPM file list is generated from the staged tree, and claims a directory
only when no package already owns it. A naive list would have the package
own /usr, /usr/bin and /usr/share, which belong to `filesystem`.

README follows the same reframe: the install goes to /usr/bin, the package
route is documented first, and the conflict is explained where a reader
meets it rather than left to be discovered.
2026-08-10 18:22:02 +07:00
gitops 620941e18b install-assets: say which recipe installs the binaries, now that one does
The header said the binaries were out of scope because `cargo` installs them.
That was true when they were built by hand in three separate checkouts. The
top-level justfile now installs all three, so the sentence pointed at the wrong
thing -- someone looking for where cosmic-conf lands would have gone to cargo
and found nothing. The scope is unchanged; only the reason was stale.
2026-08-10 17:37:31 +07:00
gitops 63610d57df ci: give this workflow a name of its own, so it stops cancelling upstream's
Both files said `name: CI`. The concurrency group is built from
${{ github.workflow }}, which is that name, so the two workflows shared a group
and cancelled each other on every push -- whichever started second would be the
only one that ever reported a result. The compositor and session forks already
say HyprCosmic for the same reason; this one was missed when it was renamed.
2026-08-10 17:32:05 +07:00
gitops 84a5eac79f Write the README for the fork, not for what it was forked from
The tree still carried cosmic-epoch's README, which describes how to install
COSMIC from a distribution's packages -- accurate, and about a different thing
than the repository it is now sitting in. Anyone arriving here needs to know
what changed, which two submodules are ours, how to build it, and what to do on
first login, and none of that was written down anywhere outside commit messages
and the comments in config/cosmic.conf.

Deliberately not a copy of upstream's. The dependency list, the packaging notes,
the per-distribution install instructions and the translation links are all
still correct and all still upstream's, so this links to them rather than
forking a second copy to go stale. What it says instead is the delta: the four
cosmic-comp patches, the session profile and what it stops starting, the
one-way config model, the theme import, the four per-user files `just install`
deliberately does not place, and what the two CI workflows each cover.

Two things in it are warnings rather than instructions, and are there because
leaving them out would make the document a nicer read and a worse one:

  - prefix must be /usr in practice, and the reason is that some files name
    /usr/share/hyprcosmic literally because they have no way to interpolate a
    prefix. Written as its own paragraph rather than a footnote.

  - `install` depends on `build`, so `sudo just install` compiles as root. That
    is upstream's arrangement, inherited rather than chosen, and says so.

Every command, path and claim in it was checked against the tree: the CLI help,
the profile's disabled list, the socket names, the power menu's five entries,
the Shortcuts merge, and the theme path HyDE's own generated config records.
2026-08-10 17:31:18 +07:00
gitops 31ce3a51c4 Build and install cosmic-conf and the shared assets from the top-level justfile
`just build` built 26 upstream components and our two forks. `just install`
installed the same set. Neither touched cosmic-conf or anything under config/,
which meant the recipe that is supposed to produce a desktop produced one with
no config compiler, no bar layout, no rofi theme and no power menu -- a
HyprCosmic session that comes up to a blank screen and cannot be logged out of.

So three lines, in the places the existing recipes already establish:

  build     cargo build --release --manifest-path cosmic-conf/Cargo.toml
  install   the binary to $prefix/bin, then tools/install-assets.sh
  clean     cosmic-conf/target

cargo directly rather than `just cosmic-conf/build-release`, because cosmic-conf
is a crate in this repository rather than a submodule and has no Justfile of its
own to delegate to.

install-assets.sh runs last and with --no-session. Last because it is the only
step whose output is worth reading: several of the files it places name
/usr/share/hyprcosmic as a literal -- a .rasi has no variables and the autostart
file is deliberately not a shell -- so a prefix other than /usr installs them
where nothing will look, and the script says which ones. --no-session because
cosmic-session/install has already placed start-hyprcosmic and
hyprcosmic.desktop by then, and installing them twice would leave it unclear
which recipe owns them.

Verified by running the two new install lines verbatim into a staging root:
eight files, the cosmic-conf binary and the seven shared assets, and no session
entry point among them.
2026-08-10 17:31:00 +07:00
gitops c6150755d2 Fetch the two forks over HTTPS, and stop cloning 29 submodules to lint CSS
Two consequences of becoming the meta-repo, neither of which the merge itself
could have caught.

The submodule URLs were written as [email protected]: because that is how they are
pushed from here. That is a working copy's business, not the repository's:
upstream names all 29 over HTTPS, a clone with no key configured is the normal
case, and CI has no key at all. The URLs are now HTTPS; each submodule's own
origin stays SSH, which is where it belongs.

The assets job asked for `submodules: recursive` back when this repository had
two of them. It now names 29, and 27 are the rest of COSMIC -- several gigabytes
fetched per run to check that some CSS is in step with its generator. It fetches
the one it needs instead.

That one is cosmic-session, because install-assets.sh also places the session
entry point that lives in that fork. The script skips those two files with a
warning when the checkout is absent rather than failing, so a fetch that
silently stopped working would quietly reduce what the job covers instead of
turning it red. Hence the `test -f` after it.
2026-08-10 17:30:45 +07:00
gitops c0ae1350f3 Merge pop-os/cosmic-epoch: become the meta-repo, with two submodules swapped
HyprCosmic started as a repository beside COSMIC holding a config compiler and a
pile of theme assets, with the two modified components hanging off it. That had
the relationship backwards. COSMIC already has a meta-repo whose whole job is to
name every component and build the desktop from them, so the honest shape for a
fork is to be that meta-repo with the components we changed pointing at our
copies -- not a separate tree that assumes the rest of COSMIC arrived some other
way.

So this merges cosmic-epoch in and repoints exactly two of its 29 submodules:

    cosmic-comp     -> outbackdingo/hyprcosmic-comp
    cosmic-session  -> outbackdingo/hyprcosmic-session

The other 27 stay on pop-os. Nothing about them needs to change, and pinning
them to copies we do not maintain would be a promise to keep 27 forks current.

`just build` and `just install` now build and install the whole desktop with our
compositor and session in it, which is what "fork COSMIC" ought to mean. The two
forks still install into /usr/libexec/hyprcosmic rather than over /usr/bin, so a
system that already has COSMIC from its distribution keeps that session on the
greeter's menu alongside this one.

Conflict resolutions worth stating:

  .github/workflows/ci.yml   upstream's kept as-is. It builds the entire desktop
                             on Arch through `just sysext`, which is precisely
                             the check a meta-repo wants and is not made less
                             useful by forking. Our per-distribution workflow
                             moved to hyprcosmic.yml beside it, the same way it
                             did in the compositor fork.

  .gitmodules                upstream's 29 entries, then two URLs rewritten.

  cosmic-comp, cosmic-session
                             ours. Git reports "no merge base" because they are
                             unrelated to the pop-os commits recorded here,
                             which is expected: they are different repositories,
                             not newer commits of the same one.

  .gitignore                 both sides, plus a note that the two forks are
                             deliberately no longer ignored.
2026-08-10 17:22:10 +07:00
gitops 98e75ecdc8 Track the two forks as submodules instead of ignoring them
They were in .gitignore with a note saying they would become submodules once
they had somewhere to live. They do now, so they are, and ignoring them would
from here on only hide which commit of each one this tree is built against.

The paths stay cosmic-comp/ and cosmic-session/, which is where cosmic-epoch
puts them, so this repository can be merged with the upstream meta-repo rather
than laid out beside it.
2026-08-10 17:20:34 +07:00
gitops 6f9696e791 config: stop baking one author's home directory into the autostart template
Two lines named /home/dingo outright: waybar's stylesheet and the wallpaper the
`current` symlink points at. This file is installed verbatim into every user's
config directory, so both worked for exactly one person -- and failed quietly
for everyone else, because a waybar whose stylesheet cannot be read still starts
and a wallpaper that was never set looks the same as one that failed to load.
Nobody would have got an error message; they would have got an unstyled bar over
a black screen.

The wallpaper line was already a shell invocation, so it only needed $HOME.

The bar was not, and this file is deliberately not a shell -- `~` and `$HOME` on
a bare command line here are literal text, which is what stops a file that names
programs from being escalated into arbitrary execution. So it now goes through
`sh -c`, on the same terms the wallpaper line already established: naming `sh`
is naming a program, and anyone who can write this file could already name any
binary on the system. `exec` keeps the process tree flat, which matters because
cosmic-session supervises these -- without it the shell would be what gets
supervised, and a waybar that died would never be restarted.

Verified that the expansion produces the same two paths that were hardcoded, and
that both exist.
2026-08-10 17:13:34 +07:00
gitops 3c1a092e51 ci: build cosmic-conf per distro, and check the assets against their generators
Two jobs, shaped differently on purpose.

cosmic-conf gets the same three-distribution container matrix the forks use,
plus one check the unit tests cannot make: it resolves the cosmic.conf this
repository actually ships. A schema change that invalidated the shipped config
would pass all 143 tests in the crate and still break every user on their first
login. The binary is invoked directly rather than through `cargo run`, because
the step redirects HOME and cargo keys its registry cache on it -- `cargo run`
would re-download every dependency into a directory the cache action does not
know about.

The assets job is where the rules that were previously only remembered become
enforced. config.jsonc is regenerated and must not move, which is what stops it
being hand-edited; the template and the generator are held to pure ASCII, which
is what stops a Nerd Font glyph being pasted somewhere it will be silently
destroyed by the next person who retypes it. install-assets.sh is then run into
a staging root and asked to verify its own work, which also exercises the audit
that refuses to install at all while any file under config/ is unclassified.

The ASCII check is written as an `if` rather than `! grep`, because grep exits 1
for "no match" and 2 for "no such file" -- negating it would turn a vanished
file into a pass, and the check would quietly stop checking anything.
2026-08-10 16:53:49 +07:00
gitops 4ba11d1bb5 Accept Hyprland's input:follow_mouse, and turn focus-follows-mouse on
Focus follows mouse was already there and already off. cosmic-comp has
supported it for as long as this fork has existed -- focus_follows_cursor and
focus_follows_cursor_delay, both live-watched -- and the schema already exposed
them under general. What was missing was the Hyprland spelling: there was no
input section at all, so a config written the way a Hyprland user would write
it named a setting that did not exist.

So this is an alias, not a new setting. input.follow_mouse and
input.follow_mouse_delay resolve to exactly the same cosmic-config keys as
general.focus_follows_cursor and its delay, and both spellings stay. Setting
both in one file is not an error; the last assignment wins, which is the rule
the rest of the file already follows. A test pins the two pairs to the same
targets, because a rebase that renames a target key would otherwise leave one
spelling working and the other quietly dead.

follow_mouse needs its own type. Hyprland writes it as a number and COSMIC
stores a bool, so Ty::FollowMouse maps 0 to false and 1 to true -- the same
trick Ty::Mode already plays for dark/light. Hyprland's 2 and 3 split pointer
focus from keyboard focus, which cosmic-comp cannot express: it has one focus
and either moves it or does not. They are rejected with a diagnostic that says
why, rather than rounded up to 1, because silently handing click-to-focus to
someone who asked for the opposite is worse than telling them the mode does not
exist here. Anything else gets the ordinary "expected 0 or 1" error.

There is no autoraise key because autoraise is not a separate feature.
raise_with_children runs inside update_active, which is what the
focus-follows-cursor timer ends up calling, so a floating window under the
pointer comes to the front as part of being focused. Tiled windows do not
overlap, so raising one is a no-op.

The delay is left at COSMIC's 250ms rather than shortened. It is what stops
focus from skating across every window the pointer crosses on its way
somewhere else, and that failure is more irritating than the wait.

Installing the binary before editing the config is the required order, not a
preference: resolution is transactional, so the old binary meeting an unknown
input section would refuse to write the whole file, not just that block.
2026-08-10 16:01:04 +07:00
gitops 08028e6f2c Add a power menu, so a session can be left without rebooting
The hyprcosmic profile disables cosmic-panel, and COSMIC's power applet lives
in that panel. Nothing replaced it, so the session had no logout, reboot or
shutdown anywhere in it: the only way out was `systemctl reboot` typed into a
terminal, which also meant every login-time change cost a reboot to test.

hyprcosmic-powermenu is a rofi menu offering lock, suspend, log out, reboot and
shut down. It is reached two ways -- $mainMod SHIFT E and a button at the right
end of waybar -- and both run the same script, so a click cannot bypass the
confirmation a keypress gets.

Logging out calls com.system76.CosmicSession.Exit, which is the method the
panel applet used and the only one that stops the session's clients in order
rather than pulling the compositor out from under them. Reboot and poweroff go
straight to systemd; polkit already authorises both for an active local session
without a prompt, verified with pkcheck, so no pkexec is involved. Lock uses
loginctl, which cosmic-greeter is listening for.

Confirmation is asked only for the three that end the session. Lock and suspend
undo themselves with a keypress, so a prompt there is pure friction; the other
three throw away everything unsaved and are one keystroke away at all times.
"No" is listed first so it is the row already selected.

The menu entries are plain words rather than Nerd Font glyphs. The bar icon is
the only glyph involved, and it comes through generate-config.py, whose whole
purpose is that no Private Use Area character is ever typed by hand -- U+F011,
confirmed present in the installed JetBrainsMono Nerd Font.

rules.css names every module id explicitly, so #custom-power had to be added
there too or the button would have rendered with no pill behind it.

The script sits under config/bin/ rather than a new top-level directory so that
install-assets.sh's audit still covers it: that check refuses to run unless
every file under config/ is classified, which is what stops a new file from
being silently left uninstalled.
2026-08-10 15:35:45 +07:00
gitops 178dfbec1a Expose preserve_split, and turn it on
`general.preserve_split` maps straight to the compositor key Patch C adds,
so opening a third window gives three panes in a row instead of quadrants.

Every other key under `general` exists upstream; this one is the fork's
own field on `CosmicCompConfig`. If a rebase ever loses that field the
compositor would ignore the key without saying anything, so the target is
pinned in a test of its own rather than left to the generic registry
checks.
2026-08-10 14:53:27 +07:00
gitops 563fdc7330 Turn autotile on, which is what makes placement automatic
COSMIC ships autotile off, so every new window opened floating at whatever
size the application asked for, on top of whatever you were looking at. The
key already existed in cosmic-conf's registry and had simply never been set.

Gaps come with it. They are invisible until windows tile -- with nothing being
laid out, nothing has a gap -- so setting one without the other is half a
change. gaps_out is doubled so the screen edge reads as margin rather than as
one more seam.

autotile_behavior is deliberately not a conf key. It defaults to Global, which
retiles workspaces that already exist rather than only arming new ones, and
that is the value worth having; anyone who wants PerWorkspace can set it in
cosmic-settings without this file overwriting them.
2026-08-10 14:31:21 +07:00
gitops b8b5ce546d Check the IPC from outside, and write down the input bug we did not solve
tools/verify-hypr-ipc.py exercises the fork's Hyprland IPC the way a client
reaches it, which the compositor's own log cannot show you. It checks the
socket names, that the five read commands answer JSON, that `bogus`,
`dispatch exec rofi`, `dispatch killactive`, `dispatch workspace +1` and
`dispatch workspace 0` are all refused, and that a real switch returns `ok`.
It moves the focused workspace, so it returns to the one you started on.

It is a validated negative test, not a hopeful one: run against the old
compositor before the socket rename was installed, it failed on the names and
exited 1.

docs/unreproducible-dead-input-2026-08-10.md records the session that came up
with no keyboard or pointer at all, and did not come back after a reboot. It
is closed deliberately rather than fixed, and most of its value is the list of
things it is not -- the fork's patches, `seats.for_device()` returning None,
the modifier-only Super binding, a shortcuts-config race, and two scary log
lines that stock COSMIC prints too. The one suspicious fact is that it was the
fourth compositor start on that boot. If it recurs, there is a list of what to
collect before rebooting destroys it.

The design spec said `.socket` and `.socket2`; corrected to the names clients
actually open.

cosmic.conf's bare-Super binding gets a comment saying why the key field is
empty, since an empty field in a `bind` line reads like a typo. COSMIC
supports modifier-only bindings and Hyprland's `bind` cannot express one.
2026-08-10 12:59:04 +07:00
gitops b89e4be10a Generate the waybar config, and fill the bar out
The bar had six modules and one dead click: pulseaudio's on-click ran
pavucontrol, which is not installed. It now opens `cosmic-settings sound`,
with middle-click as a mute toggle. Added: mpris, bluetooth, temperature,
idle_inhibitor, privacy, a swaync notification button, and power-profiles-
daemon -- and HyDE's pill styling, so each module is its own rounded chip
rather than text in a row.

No backlight module. This machine's panel has no sysfs backlight interface, so
it would render as a permanent error.

The rest of this is about the icons, which have now been got wrong enough
times to deserve a mechanism.

Private Use Area characters do not survive being typed. Writing the previous
version I put a comment at the top of the file saying every glyph was an
escape, then typed literal glyphs into the same file; the codepoint dump found
`format-bluetooth` had picked up a stray U+F293 and another field had two
glyphs where I had written one. Nothing errors -- waybar is perfectly happy to
render a label that is one space.

So the config is generated, not written. config.jsonc.in is pure ASCII with
@@TOKEN@@ placeholders, generate-config.py holds the name-to-codepoint table
and emits `\uXXXX` escapes, and it asserts its own output `.isascii()` before
writing. A hand-typed glyph now cannot reach the file. The delimiter is
doubled because single `@NAME@` collided with wpctl's `@DEFAULT_AUDIO_SINK@`,
which the generator caught as an unknown token rather than mangling.

Every codepoint was checked against the installed font with `fc-list
":charset=..."`. HyDE's own values do not all survive that: its muted glyph
U+FA80 is an old Material Design Icons codepoint that Nerd Fonts v3 moved, so
copying upstream verbatim would have shipped tofu. Three were replaced.

The font stack named "FontAwesome 6 Free" first, which resolves to the Regular
face and carries a fraction of the icon set -- the icons that did appear were
coming from accidental per-character fontconfig fallback. JetBrainsMono Nerd
Font goes first now.

One CSS note, because the failure mode is not obvious: GTK has no `:empty`
pseudo-class, and an unknown pseudo-class does not skip the rule, it rejects
the whole stylesheet and waybar exits 1. `#tray:empty` took the bar down.

install-assets.sh grows a third category. The template and the generator live
under config/ but must not be installed -- a file full of placeholders sitting
next to the real config is a coin toss for whoever opens one first -- and the
audit refuses to run until every file is classified, which is exactly what it
is for.
2026-08-10 12:58:48 +07:00
gitops bae7c5b0ff Install the system-side assets from a script, not by hand
Everything outside $HOME was placed with `sudo install` while the desktop was
being built, which left two problems.

A fresh machine has none of it, and the failure is loud in the worst way:
config.rasi imports /usr/share/hyprcosmic/rofi/{palette,rules}.rasi by absolute
path, and a missing @import is an error rofi renders *in place of the
launcher*, not a warning it skips. Miss those two files and Super+A shows a
parse error.

And hand-installed files drift. Writing this found that /usr/bin/start-
hyprcosmic had silently gained a session-logging block during the blank-screen
debugging that never made it back to the copy under version control -- found
by diffing the two on a hunch, which is not a strategy. `--check` compares
every managed file against its source and exits non-zero on any difference.

PER_USER is not documentation. The script refuses to run unless every file
under config/ is listed as either shared or per-user, so adding one forces a
decision about which it is instead of letting it be quietly left out of both.

PREFIX is only half honoured and the script says so rather than pretending
otherwise: rofi's .rasi has no variables and the autostart file is explicitly
not a shell, so both name /usr/share/hyprcosmic literally. The warning finds
them by grep rather than from a hardcoded list, so it cannot go stale -- it
already turned up waybar/style.css, which I had not thought of.

No internal sudo. It probes the nearest existing ancestor of each destination
up front and dies with the exact command to re-run, rather than escalating on
its own or failing half way through.

The session entry point comes from the cosmic-session fork, which is a
separate checkout and may be absent; it is skipped with a note when it is, or
by --no-session. Binaries are out of scope: they are build outputs, so
comparing them byte-for-byte would only ever report a rebuild.

Verified all four modes against a DESTDIR staging tree and the live /usr:
drift and missing files are detected and exit 1, a re-install repairs them, an
unclassified file under config/ is refused, a bad argument is refused, modes
land as 644 and 755, and `--check` against /usr now reports all 8 files
matching.
2026-08-10 11:48:36 +07:00
gitops 316fb843c3 Autostart: set the wallpaper through the current symlink
The line named a wallpaper inside the theme directory directly, so importing a
different theme left it pointing at a path that no longer existed -- a blank
screen at the next login, with nothing in the log to say why, because nobody
asked for a wallpaper and so nothing reported one missing.

It now names ~/.local/share/wallpapers/hyprcosmic/current, the symlink
`import-theme --assets` maintains. rofi's local.rasi shows the same image in
the launcher sidebar and names the same link, so the two cannot drift.

Changing the wallpaper is now `ln -sfn`, not an edit to this file, which the
comment says so that the next person does not undo the indirection.

The path stays literal. This file is not a shell -- deliberately, so that a
file naming programs cannot be escalated into arbitrary execution -- so `~`
and `$HOME` would be passed through as text.
2026-08-10 11:48:17 +07:00
gitops 362f324755 import-theme: write rofi's entry point and per-machine overrides
The four-layer rofi chain needs config.rasi and local.rasi to exist, and
neither has an upstream file to copy: a HyDE theme has no equivalent of
either. They were hand-written for this machine, which meant a second machine
got a launcher that reported a missing @import instead of opening.

config.rasi is `include_str!`d from config/rofi/ rather than kept as a string
literal, so it stays a real .rasi file -- highlightable, diffable, editable
without a rebuild to see the result -- and there is one copy of it rather than
two that can disagree.

local.rasi is composed per machine from two things the repo cannot know: the
theme's $ICON_THEME, and a wallpaper path. Each half is omitted entirely when
there is nothing to say, because an empty `icon-theme:` list is something rofi
would honour.

$ICON_THEME comes back as a field on Import rather than being re-parsed out of
the conf text that the same function just rendered.

The wallpaper needed a stable name. local.rasi cannot hardcode a filename
without going stale at the next theme import, so plan_wallpapers now also
maintains ~/.local/share/wallpapers/hyprcosmic/current as a symlink to one of
the copies it made. HyDE has this problem too and solves it the same way, with
~/.cache/hyde/wall.thmb. The launcher sidebar and the autostart's `awww img`
line both name the link, so they cannot drift apart.

Which wallpaper it points at is the first in *sorted* order. read_dir returns
whatever the filesystem feels like, and an arbitrary choice is fine where an
unrepeatable one is not: re-running the import would otherwise change the
wallpaper at random. The link is repointed even when every wallpaper was
skipped as already installed -- the copies are theme-specific and unchanged,
but the link is global and has to follow the theme just imported.

Repointing has to handle a *dangling* link, which is exactly what a previous
import leaves behind once its theme directory is gone: symlink(2) fails with
EEXIST rather than replacing, and Path::exists follows the link, so it answers
false for the one case that needs removing. symlink_metadata asks about the
link itself.

Values reaching a generated config come from a theme directory that may have
been downloaded from anywhere, so quote_rasi_string strips quotes, backslashes
and control characters -- .rasi has no escape syntax worth relying on.

Four existing tests counted actions and broke once every plan carried two more.
Fixed with a theme_assets() filter rather than by bumping the numbers, so what
they are actually asserting stays visible and the next generated file does not
break them again.

125 unit, 5 bin and 4 integration tests pass; clippy --all-targets and
`cargo fmt --check` are clean. A real import of Tokyo Night installed all 13
paths, and `cosmic-conf apply --diff` reports no changes afterwards.
2026-08-10 11:48:02 +07:00
gitops 1d3252aabd Give rofi a theme, in four layers
`rofi -show drun` came up unstyled. Three separate reasons, all of them
invisible: `import-theme` wrote ~/.config/rofi/theme.rasi, but rofi only ever
auto-loads config.rasi and nothing imported theme.rasi; theme.rasi holds a
HyDE palette and nothing else, no widget geometry at all; and no layout was
shipped anywhere for it to colour.

The layering mirrors what config/waybar already does, for the same reason --
a theme supplies colours, and the layout has to survive being handed a theme
that defines only some of them:

  1. palette.rasi   defaults, under the exact names HyDE themes use
  2. theme.rasi     the installed theme's rofi.theme, written by cosmic-conf
  3. rules.rasi     geometry and layout, with no colour literals at all
  4. local.rasi     per-machine paths: sidebar wallpaper, icon theme

Later imports win, so a theme recolours the launcher without rules.rasi
knowing a theme exists, and a machine points at its own wallpaper without
either of them knowing the path. Unlike waybar there is no bridge step: a HyDE
rofi.theme defines the names rules.rasi already references.

1 and 3 are shared and go under /usr/share/hyprcosmic/rofi. 2 and 4 must be
per-user, and config.rasi with them: it imports those two relatively, and rofi
resolves a relative @import against the importing file's directory.

rules.rasi is HyDE's style_1 with three deliberate departures, documented in
its header. HyDE computes the border width, radius and font in rofilaunch.sh
before invoking rofi; there is no launcher script here -- the keybinding runs
`rofi -show drun` bare -- so those are baked in. The sidebar image moves to
local.rasi because HyDE's ~/.cache/hyde/wall.thmb does not exist outside HyDE.
And dummywall gets `background-color: @main-bg` rather than transparent, so a
machine with no wallpaper set shows a panel instead of a hole.

The display-* labels are Nerd Font glyphs copied byte-for-byte out of
style_1.rasi. They are Private Use Area codepoints and do not survive being
retyped; verified as U+F303, U+F120, U+F07B, U+F2D0, each followed by a thin
space, and confirmed present in JetBrainsMono Nerd Font via
`fc-list :charset=`.

Verified with `rofi -dump-theme` and `-dump-config`: both exit 0 with empty
stderr, and the merged dump shows the Tokyo Night theme's main-bg beating the
palette default.
2026-08-10 11:47:43 +07:00
gitops 50e6c1948c Autostart cosmic-conf watch
Without it "the file wins" only held at the moment someone last ran `apply` by
hand. Now cosmic.conf is compiled at login and recompiled on every edit to it
or to anything it sources, so whatever COSMIC's settings UI has stored since
the last login is overwritten before the desktop settles.

First in the file for that reason. The bar does not read cosmic-config, so the
ordering is for the compositor's benefit rather than waybar's.

No `--config`: the default path is derived from XDG_CONFIG_HOME inside the
process, so unlike the waybar line this needs no hardcoded home directory --
the one part of this file that is not portable as written.

A malformed edit stays non-fatal. It goes to the session log and the last good
configuration remains in place, so a typo cannot strand you at a broken
desktop; fix the file and the next save applies.

Requires cosmic-conf on PATH, which it now is (/usr/bin/cosmic-conf, release
build). `waybar` and `awww-daemon` are already named bare here, so the profile
resolves argv[0] through PATH.
2026-08-10 09:49:54 +07:00
gitops 9933ff2415 Expose the watch subcommand
`watch::watch` has been written, tested and unreachable from the CLI since it
landed. It now has a command: `cosmic-conf watch [--config <path>]`, sharing
`--config` with `apply` and refusing `--diff`, which means nothing for a
daemon whose whole job is to notice a change and write it.

Exposing it made an existing wart user-visible: a single bad save reported
itself three or four times. One write arrives as several inotify events --
modify, close_write, and a rename when the editor writes atomically -- and
they do not all land inside one 250ms debounce window, so each produced its
own compile and its own copy of the same diagnostic. Consecutive identical
errors are now printed once, reset on any successful compile so the same
error after a good one is still news.

Verified against an isolated XDG_CONFIG_HOME, driving a real daemon rather
than calling `compile` directly, since none of this is reachable from the unit
tests: applies at startup, recompiles on edit, notices edits to sourced files,
picks up a `source` line added at runtime, survives a malformed edit with the
last good value intact, reports it exactly once, does not suppress a
*different* error, and resumes after a fix. Ten checks, all passing.
2026-08-10 09:42:51 +07:00
gitops cd99893b33 Fix every clippy lint, including one real panic
Mostly mechanical -- writeln! for format strings ending in a newline,
sort_by_key, slice::from_ref, &Path over &PathBuf, a stray &mut in a test.
Two were worth more than the lint that found them.

`parse_color` sliced `hex[i..i + 2]` after checking `hex.len()`. Both are byte
counts, so a multi-byte character inside `rgb(...)` split a char boundary and
panicked: `rgb(€abc)` is six bytes and aborted the compiler with "end byte
index 2 is not a char boundary". A typo in a config file must produce a
diagnostic, not a crash. Clippy did not see this -- it flagged the duplicated
`rgb(`/`rgba(` arms as foldable into `?`, and folding them is what put the two
length assumptions next to each other where the mismatch was visible. Now
guarded by is_ascii, with a test that panics without the guard.

`plan_verbatim` tripped too_many_arguments at 8. Three of them were the
`&mut Vec<Action>`, `&mut Vec<Note>` and `&mut Vec<AssetError>` threaded
through both plan helpers -- a Plan under construction, so `Draft` now names
it and `finish()` owns the errors-are-fatal rule that was previously inline.

118 unit tests plus 4 integration tests pass; `cargo fmt --check` and
`cargo clippy --all-targets` are both clean.
2026-08-10 09:23:19 +07:00
gitops a9400d0550 rustfmt, no behaviour change
rustfmt and clippy were installed all along; I had wrongly recorded them as
missing and never ran either. This is the mechanical half -- files rustfmt
reformatted and nothing else touched, committed separately so the real fixes
in the next commit are readable.
2026-08-10 09:23:08 +07:00
gitops 272ec0c5d2 Refuse arguments apply does not understand
`cosmic-conf apply --diff-only ~/.config/hyprcosmic/cosmic.conf` did exactly
the wrong thing twice over: the misspelt flag was ignored, so it wrote instead
of diffing, and the path was ignored too, so it wrote to whatever the *default*
config compiles to. It reported success either way.

Both arguments are now errors with exit 2. An argument parser that silently
skips the unknown is a bad fit for a command whose job is to overwrite
settings; the surface here is six flags, so the check is fifteen lines rather
than a dependency.
2026-08-10 09:17:36 +07:00
gitops a32596216a import-theme: say when the file it wrote is inert
Keeping the imported theme in its own file, sourced from cosmic.conf, is what
stops a re-import from clobbering the keybindings. But a sourced file only
does anything if something sources it, and until now `import-theme --out`
reported "Wrote ..." whether or not anything did -- which looks like success
while the desktop stays exactly as it was.

It now checks the sibling cosmic.conf and prints the line to add when the file
is unreachable. The match is by filename and deliberately loose: it is looking
for evidence the user already knows about the file, not parsing the config.

The shipped template carries that `source` line commented out rather than
live, because `source` naming a file that does not exist is a hard error, and
a fresh checkout has no theme.conf yet. Copying the template and running
`apply` has to work before any theme is imported.
2026-08-10 09:16:04 +07:00
gitops e46514cbab Install HyDE themes end to end
Three things stood between `assets.rs` and a themed desktop.

`import-theme` never called it. The module was written, tested and unreachable;
`--assets` now wires it up, with `--source`, `--overwrite` and `--dry-run`, and
finds the theme repo's Source/ directory by searching upward rather than
assuming HyDE's exact nesting depth.

The archive guard rejected every real icon theme. Refusing any `..` in a link
target is right for an entry path but wrong for a symlink: icon themes are
built out of relative links into sibling directories, and Tela ships thousands
of `../devices/network-wireless.svg`. What matters is whether the target
resolves inside the destination, which `stays_within_root` now decides
lexically -- no canonicalize, since the tree does not exist at plan time and
following real links during validation would be a TOCTOU window. Absolute
targets and links that climb past the root are still refused; the existing
escape tests still pass.

`apply` silently ignored `source`. It parsed and resolved inline while `watch`
went through `compile`, and `flatten` drops `Item::Source` -- so an include
that worked under `watch` vanished under `apply`. `apply` now uses `compile`
too. This matters immediately: the generated theme lives in its own
theme.conf, sourced from cosmic.conf, so re-importing a theme cannot clobber
the keybindings.

The waybar stylesheet claimed a theme could be dropped in ahead of it to
recolour the bar. It could not -- HyDE names its colours main-bg/wb-act-bg and
the rules referenced bar-bg/accent. Split into palette + theme + bridge +
rules, imported in that order, so the claim is now true. Verified by loading
the result through GTK's own CSS parser: with Tokyo Night installed main-bg
resolves to #24283b and wb-act-bg to #bb9af7; with an empty theme.css the
defaults stand. Both parse without error.

Two deliberate departures, both commented where they are made: the theme's
near-transparent bar-bg is composited at 0.85 because cosmic-comp has no blur
to put behind it, and theme.css is copied next to style.css rather than
imported from HyDE's own path, because a missing @import is fatal in GTK and
would break the bar on any machine without a theme.
2026-08-10 09:11:08 +07:00
gitops 10c5cc73d4 config: start the wallpaper daemon, under its current name
The hyprcosmic profile does not start cosmic-bg, so until now nothing was
drawing a background at all.

HyDE calls this swww, and it is packaged for Fedora after all -- the
alebastr/sway-extras COPR carries it. Upstream renamed the project to awww at
0.12 and the package Obsoletes swww < 0.12.0, so `dnf install swww` lands
awww-0.12.1. /usr/bin/swww still exists as a shim, but it prints a deprecation
warning on every invocation and its own help says it will be removed in a
future update, so the autostart line uses the real name.
2026-08-10 08:56:10 +07:00
gitops 19927bc00b cosmic-conf: translate Hyprland bind lines into COSMIC shortcuts
`bind = SUPER, D, exec, rofi -show drun` is the most recognisable line in a
hyprland.conf, and the hyprcosmic profile makes it necessary rather than just
idiomatic: with cosmic-launcher and cosmic-app-library not running, COSMIC's
stock Super, Super+/ and Super+A bindings point at nothing.

Binds are the one repeatable key in the language -- many lines fold into a
single map instead of the last one winning -- so they bypass the schema, which
is built around one conf key naming one value. They land in the Shortcuts
`custom` key, which cosmic-comp merges over `defaults`, so the system file is
untouched and reverting means deleting the lines and re-applying.

Actions are rendered as RON text rather than modelled as an enum: COSMIC's
Action has forty-odd variants, this crate deliberately does not link the cosmic
crates, and the mapping table only ever needs a handful. Dispatchers without a
genuine equivalent are refused rather than approximated, since a keybinding
that silently does the wrong thing is worse than one that fails to compile.

Verified the emitted file deserializes into cosmic-settings-config's own
`Shortcuts` type: five bindings, keysyms XK_a/XK_slash/XK_Return, Spawn actions.
2026-08-10 08:51:16 +07:00
gitops 214a77ea18 Add the waybar config and autostart file the hyprcosmic profile needs
The profile disables cosmic-panel, so without these a HyprCosmic session is a
compositor with no bar, no clock and no tray. Waybar's shipped default is not
a substitute: /etc/xdg/waybar/config.jsonc is built entirely from sway/*
modules and renders nothing under cosmic-comp.

Module choices are pinned to what cosmic-comp actually advertises on the
Wayland registry, checked against a live session rather than assumed:

  ext/workspaces  ext_workspace_manager_v1, present in stock cosmic-comp.
  wlr/taskbar     zwlr_foreign_toplevel_management_v1, absent from stock
                  cosmic-comp and supplied by this fork's Patch A. Under a
                  stock compositor the module stays empty and the bar
                  otherwise works, so the config is not fork-only.
  tray            cosmic-panel normally hosts the StatusNotifierWatcher via
                  cosmic-applet-status-area; with the panel disabled waybar
                  hosts it instead.

The stylesheet keeps every colour behind an @define-color so that a HyDE
theme's waybar.theme -- which is only a list of such declarations, the bespoke
CSS being HyDE's own rather than any theme's -- can recolour the bar by being
sourced ahead of it.

Configs install to /usr/share/hyprcosmic/waybar so the autostart file needs no
per-user paths, which matters because the profile parser is deliberately not a
shell and cannot expand ~ or $HOME.

Verified: config.jsonc parses as JSONC and every compositor module placed on
the bar has a matching config block.
2026-08-10 08:36:11 +07:00
gitops 8d21c0d084 Add a safe harness for nested session tests; ignore the cosmic-session fork
Running a second cosmic-session on the development machine turns out to be
genuinely dangerous, and twice it logged the developer out mid-session and
destroyed open work. Two distinct causes, both encoded here as guards:

  - Name-based process selection cannot distinguish the fork, the system
    install, or a stand-in binary; they all answer to `cosmic-session`. The
    second logout came from `pgrep -x cosmic-session | head -1` inside the
    test written to demonstrate that name matching is unsafe, because `head
    -1` favours the oldest match, which is always the live desktop. The
    harness therefore never selects a process by name: it spawns under
    setsid and signals `-$PGID`, with the group ID taken from `$!`.

  - A nested cosmic-session takes the well-known D-Bus name
    com.system76.CosmicSession away from the running session on a shared bus
    ("Connection `:1.3` lost name ..." in the journal), destabilising the
    outer desktop before anything is killed. The harness always runs under
    dbus-run-session. Nesting cosmic-comp alone does not need this.

It also refuses to start without WAYLAND_DISPLAY, since the winit backend
would otherwise fall back to DRM and seize the real display, and it reaps
IPC socket directories whose owning PID is gone.

Verified: shellcheck-clean syntax; the no-WAYLAND_DISPLAY guard fires; an
audit confirms every `kill` targets the script's own process group and no
code path matches a process by name. NOT verified: the harness has never
been run against the real binaries. Session-level runtime testing is now
deferred to a VM or to logging into hyprcosmic.desktop directly, rather than
nesting inside the developer's live desktop.

cosmic-session joins cosmic-comp in .gitignore; both are forks that become
submodules under the topology in the design spec.
2026-08-10 08:25:47 +07:00
gitops e56ffe8465 cosmic-conf: watch + theme asset installation
watch: source-include expansion by textual splicing so diagnostic spans stay
correct across merged files; debounced inotify; a bad edit prints diagnostics
and keeps watching rather than killing the daemon.

assets: plan/apply split mirroring emit.rs. Tarball entries and symlink/
hardlink targets are validated before extraction, sharing one routine between
plan and apply so the check cannot drift.

Added tests/archive_escape.rs as independent verification of that boundary.
The tar crate refuses to build hostile archives through its safe API, so the
fixtures write GNU header name/linkname bytes directly — the same thing a
malicious archiver does. Asserts on the filesystem afterwards rather than on
returned errors, and covers symlink indirection, where neither entry path
contains '..' yet a later write still escapes.

99 tests.
2026-08-09 22:52:33 +07:00
gitops 1d1909baaf cosmic-conf: HyDE theme importer
import-theme translates a HyDE hypr.theme into cosmic.conf, reusing the
Phase 1 parser — which is the payoff for choosing Hyprland-style syntax.

Nothing is dropped silently. Every source key either lands in the output or
carries a Note explaining why not, classified as NoEquivalent,
NeedsCompositorPatch, DifferentProgram or Lossy. Gradient borders contribute
their first stop as the accent and say so.

Handles real-world quirks found in actual theme files: HyDE's |> destination
header (no '=', would otherwise be a parse error), colon-keys like
shadow:enabled, and nested blur blocks.

Tests run against the verbatim Catppuccin-Mocha theme, and assert that the
generated conf both parses and resolves against the registry. Verified
end-to-end on Tokyo-Night, a theme absent from the tests: import -> apply
produced 8 correct cosmic-config files with 20 settings reported.

64 tests.
2026-08-09 22:35:28 +07:00
gitops dee24ac30a cosmic-conf: emit + CLI
emit writes RON directly rather than linking libcosmic. Spike 2 showed
cosmic-config is a filesystem KV store whose notify watcher keys off file
paths (lib.rs:377), so an atomic write is observed identically to the typed
API — for the cost of ron instead of the whole libcosmic graph.

Two-stage: plan renders without touching disk, apply writes. Composites are
read-modify-write against verified upstream defaults — gaps (0,8) at
theme.rs:939, CornerRadii at corner.rs:20-31 — so setting one field never
drops its siblings. Unmodelled composites error rather than write blind.

52 tests. CLI verified end-to-end: apply, --diff (writes nothing), idempotent
rerun, partial update preserving siblings, and multi-diagnostic failure with
exit 1 and zero writes.
2026-08-09 22:31:40 +07:00
gitops 09f90f65de cosmic-conf: parser, schema registry, resolve with projection folding
Spike results corrected the spec: ThemeBuilder.gaps is (outer, inner) at
cosmic-theme/src/model/theme.rs:895, lives under CosmicTheme.{Dark,Light}.Builder
rather than CosmicTk, and fans out to two components. Entry therefore carries
targets: &[Target].

34 tests, including the folding property that keeps gaps_out from clobbering
gaps_in. Bare #rrggbb colours rejected: # begins a comment, as in Hyprland.
2026-08-09 22:15:57 +07:00
gitops 39008709e3 Add HyprCosmic design spec
HyDE-style desktop on cosmic-comp: single-file Hyprland-idiom config
compiler, wlr-foreign-toplevel + Hyprland-compatible IPC patches, waybar
in place of cosmic-panel.
2026-08-09 22:08:41 +07:00
Jeremy Soller 0ce7ec30ac Update submodules 2026-07-29 09:15:17 -06:00
Jeremy Soller 652b4c45f1 Update submodules 2026-07-22 10:39:52 -06:00
Jeremy Soller a483257d31 Add cosmic-sound-theme 2026-07-21 10:24:24 -06:00
Jeremy Soller 6eceadf650 Update submodules 2026-07-14 16:17:56 -06:00
Jeremy Soller 0098ab4d8f Update submodules 2026-06-30 14:03:15 -06:00
Jeremy Soller 3e34c8e746 Update submodules 2026-06-23 13:50:45 -06:00
Jeremy Soller 0d128858ad Add cosmic-monitor 2026-06-23 08:36:37 -06:00
Jeremy Soller f846fcb546 Update for 1.0.16 tags 2026-06-10 11:37:11 -06:00
Jeremy Soller 43bcd5266f Update for 1.0.15 tags 2026-06-03 10:51:13 -06:00
Jeremy Soller a21edb170b Update for 1.0.14 tags 2026-05-26 13:51:19 -06:00
Jeremy Soller ed1607856b Update for 1.0.13 tags 2026-05-12 13:10:23 -06:00
Jeremy Soller 4412bb00d7 Update for 1.0.12 tags 2026-05-05 14:59:44 -06:00
Jeremy Soller 6169da68ca Update for 1.0.11 tags 2026-04-21 14:46:11 -06:00
Jeremy Soller d103b64024 Update translations 2026-04-14 10:34:47 -06:00
Jeremy Soller 9af2199729 Update for 1.0.10 tags 2026-04-14 09:47:35 -06:00
Jeremy Soller 9e8ac99bcb Update for 1.0.9 tags 2026-04-07 14:34:23 -06:00
Jeremy Soller efe2c51b53 Prepare for next tag 2026-02-24 10:27:40 -07:00
Jeremy Soller 9b0e61c54b Prepare for next tag 2026-02-17 14:17:02 -07:00
70 changed files with 10314 additions and 337 deletions
+209
View File
@@ -0,0 +1,209 @@
# Build and check the parts of HyprCosmic that are not one of the two forks:
# cosmic-conf, the shared waybar/rofi assets, and the installer that places them.
#
# Two jobs with different shapes on purpose. cosmic-conf is compiled code and
# gets the same per-distribution matrix the forks do. The assets are text, and
# text does not care which distribution it is on -- what matters there is whether
# generated files are still in step with their generator, which is a single
# question with a single answer.
# The name is not "CI", which is upstream's ci.yml. Two workflows sharing a name
# share `${{ github.workflow }}`, and the concurrency group below is built from
# it -- they would cancel each other on every push, and whichever started second
# would be the only one that ever reported.
name: HyprCosmic
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
cosmic-conf:
name: cosmic-conf (${{ matrix.distro }})
runs-on: ubuntu-latest
container: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- distro: fedora
image: fedora:latest
- distro: debian
image: debian:bookworm
- distro: arch
image: archlinux:latest
steps:
# Before checkout: actions/checkout needs git and these images are bare.
- name: Install build dependencies (fedora)
if: matrix.distro == 'fedora'
run: dnf -y install --setopt=install_weak_deps=False git curl gcc
- name: Install build dependencies (debian)
if: matrix.distro == 'debian'
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
git curl ca-certificates build-essential
- name: Install build dependencies (arch)
if: matrix.distro == 'arch'
run: pacman -Syu --noconfirm --needed git curl base-devel
- uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --profile minimal \
--component clippy
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: Swatinem/rust-cache@v2
with:
workspaces: cosmic-conf
key: ${{ matrix.distro }}
- name: Test
working-directory: cosmic-conf
run: cargo test --locked
# Warnings are errors here because the alternative is a build log nobody
# reads and a lint that has been failing for six months.
- name: Clippy
working-directory: cosmic-conf
run: cargo clippy --all-targets --locked -- -D warnings
- name: Build
working-directory: cosmic-conf
run: cargo build --release --locked
# The end-to-end question the unit tests cannot ask: does the cosmic.conf
# this repository actually ships still parse and resolve? A schema change
# that invalidates the shipped config would pass every test in the crate
# and break every user on first login.
#
# HOME is redirected so the resolution reports against an empty config
# tree rather than the runner's own. The binary is run directly instead of
# through `cargo run`: cargo keys its registry cache on HOME, so moving
# HOME would send it off to re-download every dependency into a directory
# the cache action does not know about.
#
# --diff writes nothing, and the last two lines hold it to that.
- name: The shipped cosmic.conf still resolves
working-directory: cosmic-conf
run: |
set -eux
fake="$RUNNER_TEMP/fakehome"
rm -rf "$fake"
mkdir -p "$fake/.config"
env HOME="$fake" XDG_CONFIG_HOME="$fake/.config" \
./target/release/cosmic-conf apply --diff --config ../config/cosmic.conf
test -z "$(find "$fake" -type f -print -quit)"
- uses: actions/upload-artifact@v4
with:
name: cosmic-conf-${{ matrix.distro }}
path: cosmic-conf/target/release/cosmic-conf
retention-days: 14
assets:
name: assets and installer
runs-on: ubuntu-latest
steps:
# Not `submodules: recursive`. This repository names 29 of them, 27 of
# which are the rest of COSMIC and none of which this job touches -- the
# only one needed is cosmic-session, because tools/install-assets.sh also
# places the session entry point that lives there. Cloning the other 27 to
# lint some CSS would cost several gigabytes per run.
#
# The script degrades gracefully when that checkout is absent -- it warns
# and skips those two files -- so a failure to fetch would quietly reduce
# what is covered rather than fail. Hence the assertion after it.
- uses: actions/checkout@v4
- name: Fetch only the session submodule
run: |
set -eux
git submodule update --init --depth 1 cosmic-session
test -f cosmic-session/data/start-hyprcosmic
# config.jsonc is generated, and the repository's rule is that it is never
# hand-edited: every Nerd Font glyph in it comes from a codepoint table in
# generate-config.py, because Private Use Area characters are destroyed by
# being retyped and indistinguishable in a diff. That rule is currently
# enforced by remembering it. This enforces it instead -- regenerate, and
# the file must not move.
- name: The generated waybar config is in step with its generator
run: |
set -eux
python3 config/waybar/generate-config.py \
config/waybar/config.jsonc.in config/waybar/config.jsonc
git diff --exit-code -- config/waybar/config.jsonc
# No PUA character may reach the template or the generator's own source.
# The generator refuses to emit non-ASCII, but nothing stopped one being
# pasted into its inputs until here.
#
# Written as an `if` rather than `! grep ...` because grep exits 1 for "no
# match" and 2 for "no such file", and negating it would turn a vanished
# file into a pass -- the check would quietly stop checking anything.
- name: The template and generator stay pure ASCII
run: |
set -eu
for f in config/waybar/config.jsonc.in config/waybar/generate-config.py; do
test -f "$f"
if LC_ALL=C grep -Pn '[^\x00-\x7F]' "$f"; then
echo "$f: non-ASCII above. Glyphs belong in the codepoint table," \
"not in the template." >&2
exit 1
fi
done
# A login-time script with a syntax error is a black screen with nowhere to
# print the reason.
- name: Syntax-check the shell scripts
run: |
set -eux
bash -n config/bin/hyprcosmic-powermenu
bash -n tools/install-assets.sh
# Both scripts are shellcheck-clean today, so this starts as a ratchet
# rather than a backlog. The runner image ships shellcheck; the install is
# there so that stopping to be true is a slow step and not a broken job.
- name: Shellcheck
run: |
set -eux
command -v shellcheck >/dev/null || {
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck
}
shellcheck config/bin/hyprcosmic-powermenu tools/install-assets.sh
# A round trip. Installing into a staging root and then asking --check to
# confirm it exercises both halves of the script against each other, and
# the audit it runs first refuses to proceed at all unless every file under
# config/ is classified as shared, per-user or a generator input. That
# audit is the real test: it is what stops a new file being silently left
# out of the install.
- name: Install into a staging root, then verify it
run: |
set -eux
DESTDIR="$PWD/stage" ./tools/install-assets.sh
DESTDIR="$PWD/stage" ./tools/install-assets.sh --check
find stage -type f -printf '%M %10s %P\n'
- uses: actions/upload-artifact@v4
with:
name: hyprcosmic-assets
path: stage/
retention-days: 14
+404
View File
@@ -0,0 +1,404 @@
# Build installable HyprCosmic packages for Fedora, Arch and Debian.
#
# WHY THIS IS A SEPARATE WORKFLOW FROM hyprcosmic.yml
# --------------------------------------------------
# hyprcosmic.yml beside it answers "does the fork still build and do the assets
# still install where they claim to", on every push, in a few minutes. This one
# compiles 27 Rust components three times over and takes hours. Sharing a file
# would mean either running the slow thing on every push or never running the
# fast thing on a tag, and a `if:` guard threaded through a shared matrix to
# avoid that is harder to read than two files.
#
# WHY IT DOES NOT RUN ON EVERY PUSH
# ---------------------------------
# Three full desktop builds per commit is hours of runner time to produce
# artifacts nobody downloads. Tags get packages because that is when a package
# means something; workflow_dispatch covers wanting one at any other time.
#
# WHY THE WHOLE JOB RUNS IN A CONTAINER
# -------------------------------------
# Nothing here is statically linked, so a package is only valid on the
# distribution that built it -- an RPM built on Ubuntu's runner would name
# Ubuntu's sonames and refuse to install on Fedora. `container:` puts the
# compile, the staging and the package build all inside an image of the target,
# so the sonames recorded are the ones that will exist on the machine installing
# it. This is also why no step here runs on a developer's workstation: the
# distribution being targeted is rarely the one being typed at.
name: Packages
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Version to stamp on the packages'
required: false
default: '0.1.0'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
package:
name: ${{ matrix.distro }}
runs-on: ubuntu-latest
container: ${{ matrix.image }}
# GitHub defaults `run:` to `sh -e {0}` inside a container, and on Debian
# that is dash. Several steps below use brace expansion and process
# substitution, which dash does not have and which bash-invoked-as-sh
# disables. Naming bash once here is better than writing POSIX around a
# constraint no target actually imposes -- all three images ship bash.
defaults:
run:
shell: bash
strategy:
# One distribution failing on a package name is worth seeing on its own,
# and the other two artifacts are still worth having.
fail-fast: false
matrix:
include:
- distro: fedora
image: fedora:44
- distro: arch
image: archlinux:base-devel
# trixie rather than the bookworm the other workflows use. bookworm has
# no `just` package -- it arrived in trixie -- and its rustc is 1.63,
# so `just` would have to be compiled by a toolchain installed before
# the thing that installs toolchains. CI pays that to prove the crate
# builds on the oldest supported Debian; a package has no such point
# to make.
- distro: debian
image: debian:trixie
steps:
# Before checkout, deliberately: actions/checkout needs git in the image
# and these are bare.
#
# The library lists are the union of Build-Depends across all 27 upstream
# components' debian/control files, translated per distribution rather
# than trimmed. Derived mechanically rather than assembled by hand, after
# a hand-assembled list built for eighteen minutes and then failed on
# dav1d -- a dependency of cosmic-bg that nothing in the obvious set names.
#
# The Fedora and Arch translations were checked against dnf repoquery and
# archlinux.org's package API rather than guessed, because a name that
# does not exist fails the whole step: it is libdav1d-devel on Fedora, not
# dav1d-devel, and ttf-opensans on Arch, not otf-opensans.
#
# The Fedora list names a set of basic tools the other two get for free.
# Fedora's container image is deliberately minimal: no `which` (deprecated
# there in favour of `command -v`), no make, no findutils, no tar. Arch's
# base-devel and Debian's build-essential plus its essential set include
# all of them, which is why this only ever broke on one of the three.
#
# These are not guesses. The top-level justfile opens with
# `make := \`which make\``, and the submodules' own recipes shell out to
# find, xargs, tar and sed -- 114 tar invocations and 18 find between
# them. Several steps in this workflow use find as well.
- name: Install build dependencies (fedora)
if: matrix.distro == 'fedora'
run: |
dnf -y install --setopt=install_weak_deps=False \
git curl ca-certificates just \
gcc gcc-c++ make which findutils tar gzip sed diffutils \
cmake pkgconf-pkg-config nasm lld mold \
clang-devel llvm-devel \
desktop-file-utils rpm-build intltool ImageMagick open-sans-fonts \
dbus-devel expat-devel fontconfig-devel freetype-devel \
libinput-devel libseat-devel libxkbcommon-devel \
mesa-libgbm-devel mesa-libEGL-devel libglvnd-devel \
wayland-devel libdisplay-info-devel libdav1d-devel \
pixman-devel cairo-devel pango-devel glib2-devel gtk3-devel gtk4-devel \
pipewire-devel pulseaudio-libs-devel \
gstreamer1-devel gstreamer1-plugins-base-devel \
flatpak-devel systemd-devel libgudev-devel \
openssl-devel pam-devel libxml2-devel xkeyboard-config-devel
# Shorter than the others, and not by omission: Arch ships headers in the
# main package rather than splitting a -devel, so `wayland` here is
# `wayland-devel` on Fedora.
- name: Install build dependencies (arch)
if: matrix.distro == 'arch'
run: |
pacman -Syu --noconfirm --needed \
git curl just cmake pkgconf nasm lld mold clang llvm \
desktop-file-utils sudo intltool imagemagick ttf-opensans \
dbus expat fontconfig freetype2 \
libinput seatd libxkbcommon \
mesa libglvnd wayland libdisplay-info dav1d \
pixman cairo pango glib2 gtk3 gtk4 \
pipewire libpipewire libpulse gst-plugins-base-libs \
flatpak systemd-libs libgudev \
openssl pam libxml2 xkeyboard-config
- name: Install build dependencies (debian)
if: matrix.distro == 'debian'
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
git curl ca-certificates just \
build-essential cmake pkg-config nasm lld mold \
clang libclang-dev llvm-dev \
desktop-file-utils dpkg-dev fakeroot file \
intltool imagemagick fonts-open-sans \
libdbus-1-dev libexpat1-dev libfontconfig-dev libfreetype-dev \
libinput-dev libseat-dev libxkbcommon-dev \
libgbm-dev libegl-dev libegl1-mesa-dev libgles-dev \
libwayland-dev libdisplay-info-dev libdav1d-dev \
libpixman-1-dev libcairo2-dev libpango1.0-dev libglib2.0-dev \
libgtk-3-dev libgtk-4-dev \
libpipewire-0.3-dev libspa-0.2-dev libpulse-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
libflatpak-dev libsystemd-dev libudev-dev libgudev-1.0-dev \
libssl-dev libpam0g-dev libxml2-dev xkb-data libxcb1-dev
# submodules: recursive is the whole point -- this repository is 27
# components plus the two forks, and a checkout without them builds
# nothing.
- uses: actions/checkout@v4
with:
submodules: recursive
# stable as the default, not `none`.
#
# The two fork workflows use --default-toolchain none and let
# rust-toolchain.toml decide, which is right there because the checkout
# root is the crate and the pin sits in it. Here it does not: the pin is
# cosmic-comp/rust-toolchain.toml, one level down, and this job's other 27
# components have no pin at all. With `none` there is no default to fall
# back to and the first cargo invocation at the repository root fails
# before anything is built.
#
# Naming stable does not weaken the pin. rustup applies a directory-local
# rust-toolchain.toml whenever it enters that directory and installs it on
# demand, so cosmic-comp still compiles with the 1.93 it asks for while
# everything else uses stable.
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Show toolchain
run: |
rustup show
cargo --version
just --version
# No Swatinem/rust-cache here, unlike the two fork workflows.
#
# It was tried and it fails on this repository specifically: the action
# runs `cargo metadata` at the workspace root to work out what to cache,
# and this root is a meta-repository with no Cargo.toml -- it is 29
# submodules, each its own crate with its own target/. The action reported
# `could not find Cargo.toml in /__w/hyprcosmic/hyprcosmic` and cached
# nothing.
#
# Listing all 29 workspaces would fix the error and create a worse
# problem: their target directories come to roughly 17 GB, against a 10 GB
# per-repository cache limit, so the jobs would evict each other's entries
# every run and pay upload time for the privilege. A cold build is the
# honest cost of a workflow that only runs on tags and on demand.
- name: Determine version
id: ver
run: |
set -eux
# A tag is authoritative; a manual run uses its input; anything else
# falls back so the job is still testable from a branch.
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
v="${GITHUB_REF_NAME#v}"
else
v="${{ inputs.version || '0.1.0' }}"
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
- name: Build
run: just build
# prefix=/usr, not the justfile's /usr/local default. Both .desktop files
# name an absolute Exec under /usr/bin -- a desktop entry cannot
# interpolate a prefix -- so any other prefix stages entries pointing at
# paths this step did not write.
- name: Stage the install
run: just install "$PWD/stage" /usr
# The staged tree is what all three packages wrap, so it is worth failing
# here rather than shipping a package that is missing the compositor. The
# negative assertion is the one that would rot quietly: nothing may return
# to the private libexec layout this fork used to install into, because a
# copy there is a second compositor that nothing runs and no uninstall
# removes.
- name: Assert the staged tree is a complete desktop
run: |
set -eux
test -x stage/usr/bin/cosmic-comp
test -x stage/usr/bin/cosmic-session
test -x stage/usr/bin/cosmic-conf
test -x stage/usr/bin/start-hyprcosmic
test -x stage/usr/bin/start-cosmic
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
test -f stage/usr/share/wayland-sessions/cosmic.desktop
test -f stage/usr/lib/systemd/user/cosmic-session.target
test -f stage/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1/defaults
test -d stage/usr/share/hyprcosmic
test ! -e stage/usr/libexec/hyprcosmic
echo "staged files: $(find stage -type f | wc -l)"
# ---- Fedora -------------------------------------------------------
#
# The file list is generated rather than written into the spec. Across 27
# components a hand-maintained %files would be stale within a week, and
# stale in the direction that omits files nobody misses until a login
# fails.
#
# Directories need care. A %dir line for every staged directory would
# have the package claim /usr, /usr/bin and /usr/share, which the
# `filesystem` package owns -- the RPM would build fine and then refuse to
# install, or worse, take those directories with it on uninstall. So a
# directory is only claimed if no package on the build system already owns
# it, which leaves exactly the ones this fork creates
# (/usr/share/hyprcosmic and friends).
- name: Build the RPM
if: matrix.distro == 'fedora'
run: |
set -eux
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
: > files.list
find stage -mindepth 1 -type d -printf '%P\n' | while read -r d; do
rpm -qf --quiet "/$d" || printf '%%%%dir "/%s"\n' "$d" >> files.list
done
find stage -mindepth 1 \! -type d -printf '"/%P"\n' >> files.list
wc -l files.list
rpmbuild -bb packaging/fedora/hyprcosmic.spec \
--define "_topdir $PWD/rpmbuild" \
--define "stagedir $PWD/stage" \
--define "filelist $PWD/files.list" \
--define "ver ${{ steps.ver.outputs.version }}"
mkdir -p dist
find rpmbuild/RPMS -name '*.rpm' -exec cp -v {} dist/ \;
# A package that installs is the claim being made, so it is tested rather
# than assumed. --setopt=tsflags=test does the whole resolution and
# conflict check without writing to the container.
- name: Verify the RPM
if: matrix.distro == 'fedora'
run: |
set -eux
rpm -qpi dist/*.rpm
rpm -qp --requires dist/*.rpm
dnf -y install --setopt=tsflags=test dist/*.rpm
# ---- Arch ---------------------------------------------------------
#
# makepkg refuses to run as root, and a container is root by default, so
# the build runs as an unprivileged user that owns the tree it reads.
# --nodeps because the depends array names a running system's runtime
# libraries, which this image has no reason to hold, and nothing is being
# compiled at this point anyway.
- name: Build the Arch package
if: matrix.distro == 'arch'
run: |
set -eux
useradd -m builder
mkdir -p dist
cp packaging/arch/PKGBUILD .
chown -R builder:builder .
sudo -u builder \
HYPRCOSMIC_STAGEDIR="$PWD/stage" \
HYPRCOSMIC_VERSION="${{ steps.ver.outputs.version }}" \
PKGDEST="$PWD/dist" \
makepkg --nodeps --noconfirm
- name: Verify the Arch package
if: matrix.distro == 'arch'
run: |
set -eux
pacman -Qip dist/*.pkg.tar.zst
# Contents rather than an install: pacman has no dry run that resolves
# dependencies without touching the filesystem, and this image is not
# a desktop, so a real install would fail on runtime libraries that
# say nothing about whether the package is well formed.
pacman -Qlp dist/*.pkg.tar.zst | head -20
# ---- Debian -------------------------------------------------------
#
# dpkg-deb --build over a staged tree, rather than a full source package.
# The Depends line is computed by dpkg-shlibdeps from the binaries
# themselves rather than written by hand -- 27 components link against
# more libraries than anyone will keep an accurate list of, and a hand
# list is wrong in the direction that installs and then fails to start.
#
# dpkg-shlibdeps insists on a debian/control in the working directory even
# when invoked outside a source package, hence the stub.
- name: Build the Debian package
if: matrix.distro == 'debian'
run: |
set -eux
mkdir -p debian
printf 'Source: hyprcosmic\n\nPackage: hyprcosmic\nArchitecture: amd64\n' > debian/control
binaries=$(find stage -type f -perm -100 -exec sh -c 'file -b "$1" | grep -q ELF && echo "$1"' _ {} \;)
dpkg-shlibdeps -O --ignore-missing-info $binaries > shlibdeps.txt
deps=$(sed 's/^shlibs:Depends=//' shlibdeps.txt)
size=$(du -sk stage | cut -f1)
mkdir -p stage/DEBIAN
sed -e "s|@VERSION@|${{ steps.ver.outputs.version }}|" \
-e "s|@INSTALLED_SIZE@|$size|" \
-e "s|@SHLIB_DEPENDS@|$deps|" \
packaging/debian/control.in > stage/DEBIAN/control
cat stage/DEBIAN/control
mkdir -p dist
dpkg-deb --build --root-owner-group stage \
"dist/hyprcosmic_${{ steps.ver.outputs.version }}_amd64.deb"
- name: Verify the Debian package
if: matrix.distro == 'debian'
run: |
set -eux
dpkg-deb --info dist/*.deb
dpkg-deb --contents dist/*.deb | head -20
# lintian is not installed and would fail this package on a dozen
# policy points that do not apply to a desktop fork shipped outside
# the archive. What matters here is that dpkg can read it back.
dpkg-deb --fsys-tarfile dist/*.deb | tar -tf - >/dev/null
- uses: actions/upload-artifact@v4
with:
name: hyprcosmic-${{ matrix.distro }}
path: dist/
retention-days: 30
# Only on a tag. A dispatch run is for getting artifacts to try, and turning
# one into a public release would make every experiment look like a shipped
# version.
release:
needs: package
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Attach the packages to the release
uses: softprops/action-gh-release@v2
with:
files: dist/*
# Draft, deliberately. These packages conflict with the
# distribution's cosmic-comp and cosmic-session, so installing one
# replaces the machine's desktop. That is worth a human reading the
# notes before it is published rather than a tag push making it
# available.
draft: true
generate_release_notes: true
+13 -1
View File
@@ -1,3 +1,15 @@
# Upstream cosmic-epoch's entries
.vscode
cosmic-sysext
*_build
*_build
# Rust
target/
**/*.rs.bk
vendor/
# OMC operational artifacts
.omc/
# The two forks are NOT ignored. They are submodules -- see .gitmodules -- and
# ignoring them would only hide the commit each one is pinned to.
+10 -2
View File
@@ -1,10 +1,10 @@
[submodule "cosmic-session"]
path = cosmic-session
url = https://github.com/pop-os/cosmic-session
url = https://github.com/outbackdingo/hyprcosmic-session
branch = master
[submodule "cosmic-comp"]
path = cosmic-comp
url = https://github.com/pop-os/cosmic-comp
url = https://github.com/outbackdingo/hyprcosmic-comp
branch = master
[submodule "cosmic-panel"]
path = cosmic-panel
@@ -106,3 +106,11 @@
path = pop-launcher
url = https://github.com/pop-os/launcher.git
branch = master
[submodule "cosmic-monitor"]
path = cosmic-monitor
url = https://github.com/pop-os/cosmic-monitor.git
branch = master
[submodule "cosmic-sound-theme"]
path = cosmic-sound-theme
url = https://github.com/pop-os/cosmic-sound-theme.git
branch = master
+248 -305
View File
@@ -1,344 +1,287 @@
# COSMIC Desktop
# HyprCosmic
[COSMIC](https://system76.com/cosmic) is a desktop environment offering performance, efficiency, and personalization to empower a wide variety of use cases.
COSMIC's compositor, driven the way Hyprland is configured, wearing a HyDE
shell.
## Components of COSMIC Desktop
* [cosmic-applets](https://github.com/pop-os/cosmic-applets)
* [cosmic-applibrary](https://github.com/pop-os/cosmic-applibrary)
* [cosmic-bg](https://github.com/pop-os/cosmic-bg)
* [cosmic-comp](https://github.com/pop-os/cosmic-comp)
* [cosmic-edit](https://github.com/pop-os/cosmic-edit)
* [cosmic-files](https://github.com/pop-os/cosmic-files)
* [cosmic-greeter](https://github.com/pop-os/cosmic-greeter)
* [cosmic-icons](https://github.com/pop-os/cosmic-icons)
* [cosmic-idle](https://github.com/pop-os/cosmic-idle)
* [cosmic-initial-setup](https://github.com/pop-os/cosmic-initial-setup)
* [cosmic-launcher](https://github.com/pop-os/cosmic-launcher)
* [cosmic-notifications](https://github.com/pop-os/cosmic-notifications)
* [cosmic-osd](https://github.com/pop-os/cosmic-osd)
* [cosmic-panel](https://github.com/pop-os/cosmic-panel)
* [cosmic-player](https://github.com/pop-os/cosmic-player)
* [cosmic-randr](https://github.com/pop-os/cosmic-randr)
* [cosmic-screenshot](https://github.com/pop-os/cosmic-screenshot)
* [cosmic-session](https://github.com/pop-os/cosmic-session)
* [cosmic-settings](https://github.com/pop-os/cosmic-settings)
* [cosmic-settings-daemon](https://github.com/pop-os/cosmic-settings-daemon)
* [cosmic-store](https://github.com/pop-os/cosmic-store)
* [cosmic-term](https://github.com/pop-os/cosmic-term)
* [cosmic-theme-editor](https://github.com/pop-os/cosmic-theme-editor)
* [cosmic-workspaces-epoch](https://github.com/pop-os/cosmic-workspaces-epoch)
* [xdg-desktop-portal-cosmic](https://github.com/pop-os/xdg-desktop-portal-cosmic)
* [pop-launcher](https://github.com/pop-os/launcher)
It is a fork of [cosmic-epoch](https://github.com/pop-os/cosmic-epoch), the
meta-repository that names every COSMIC component and builds the desktop out of
them. Two of its 29 submodules point at forks; the other 27 are System76's,
unchanged. So this is not a re-implementation of COSMIC and not a theme pack
sitting beside it — it is COSMIC, built from source, with a different shell on
top and a different way of telling it what to do.
### COSMIC libraries/crates
Three things distinguish a HyprCosmic session from a COSMIC one:
* [cosmic-protocols](https://github.com/pop-os/cosmic-protocols)
* [cosmic-text](https://github.com/pop-os/cosmic-text)
* [cosmic-theme](https://github.com/pop-os/cosmic-theme)
* [cosmic-time](https://github.com/pop-os/cosmic-time)
- **Hyprland's configuration idiom.** A single `~/.config/hyprcosmic/cosmic.conf`
with `general { }` blocks, `bind =` lines and `$variables` is compiled into
COSMIC's config tree. The file wins: what it names, it owns.
- **HyDE's shell.** waybar instead of cosmic-panel, rofi instead of
cosmic-launcher, `awww` instead of cosmic-bg. HyDE themes are imported
directly, palette and wallpapers and all.
- **It replaces COSMIC rather than sitting next to it.** The binaries install as
`/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, the paths a cosmic-comp
and a cosmic-session go to, and the packages conflict with the distribution's
accordingly. Both session entries are installed, so the greeter still offers a
stock COSMIC shell for the day the HyDE one does not start — now served by
these binaries rather than by a second copy on disk.
### COSMIC toolkit for apps and applets
## Repository layout
* [libcosmic](https://github.com/pop-os/libcosmic)
Everything in `cosmic-epoch`, plus:
## Installing on Pop!\_OS
| Path | What it is |
| --- | --- |
| `cosmic-comp/` | submodule → [outbackdingo/hyprcosmic-comp](https://github.com/outbackdingo/hyprcosmic-comp) |
| `cosmic-session/` | submodule → [outbackdingo/hyprcosmic-session](https://github.com/outbackdingo/hyprcosmic-session) |
| `cosmic-conf/` | the config compiler and HyDE theme importer. A crate in this repository, not a submodule |
| `config/` | the shipped `cosmic.conf`, `autostart`, waybar and rofi assets, and the power menu |
| `tools/install-assets.sh` | installs the parts of `config/` that live outside `$HOME`, and `--check`s them for drift |
| `docs/` | the design spec, a debugging guide, and one written-up bug that is still open |
### Pop!\_OS 24.04
The other 27 submodules stay on `pop-os`. Nothing about them needs to change,
and pinning them to copies nobody maintains would be a promise to keep 27 forks
current.
COSMIC DE's first release (Epoch 1) is included in Pop!\_OS 24.04. There are two ways to get the 24.04 release:
### What the two forks change
- Install it from the [latest release ISO](https://system76.com/cosmic/).
- Upgrade an existing Pop!\_OS 22.04 installation using the following command: `pop-upgrade release upgrade -f`
- If you experience problems during the upgrade, please open an issue in the [pop-upgrade GitHub repository](https://github.com/pop-os/upgrade) or join the [Pop!\_OS Mattermost chat server](https://chat.pop-os.org) for assistance.
**cosmic-comp** — four patches, each independent:
COSMIC users, including Pop!_OS users, are welcome to join the [Pop!\_OS Mattermost chat server](https://chat.pop-os.org) to receive news about development. Join the [COSMIC Epoch channel](https://chat.pop-os.org/pop-os/channels/cosmic-epoch) for COSMIC user discussion, or the [Development channel](https://chat.pop-os.org/pop-os/channels/development) for developer-oriented discussion.
- `zwlr_foreign_toplevel_management_v1`, which is the protocol waybar's window
list and rofi's window mode read. Without it the taskbar is empty.
- A Hyprland-compatible IPC socket (`.socket.sock` and the `.socket2.sock` event
stream) under the names Hyprland clients actually open, so HyDE's scripts and
waybar's `hyprland/*` modules work unmodified. The write surface is
deliberately small: `dispatch exec` and `dispatch killactive` are rejected,
because this is the surface any process that can open the socket gets.
- New windows open *beside* the focused window rather than inside it.
- The install goes to `/usr/bin/cosmic-comp`, at upstream's paths and alongside
upstream's two `.ron` defaults files, which are carried unmodified.
### Pop!\_OS 22.04
**cosmic-session** — profiles. `HYPRCOSMIC_PROFILE=hyprcosmic` (set by
`hyprcosmic.desktop`) skips cosmic-panel, cosmic-launcher, cosmic-app-library,
cosmic-workspaces, cosmic-bg and cosmic-files-applet, then starts whatever
`~/.config/hyprcosmic/autostart` names. cosmic-greeter is deliberately *not*
skippable — a display manager is the easiest thing to lock yourself out of. The
fork installs three files where upstream installs seven; the four it drops are
owned by the distribution's own `cosmic-session` package and writing them would
make the two conflict.
Due to dependency requirements, **COSMIC Epoch is no longer receiving updates on Pop!\_OS 22.04 LTS.** It's no longer recommended to test COSMIC Epoch on Pop!\_OS 22.04 because the latest bug fixes and features are only available on newer distributions such as Pop!\_OS 24.04.
Individual COSMIC applications work in the default GNOME session of Pop!\_OS 22.04. You can install individual COSMIC applications using the following command:
```
sudo apt install cosmic-edit cosmic-files cosmic-player cosmic-store cosmic-term
```
#### Old Release on 22.04
An **older release** of the COSMIC Epoch desktop environment alpha is still available on Pop!\_OS 22.04 LTS. If you encounter bugs while testing COSMIC Epoch on Pop!\_OS 22.04, please check if they exist in Pop!\_OS 24.04 before reporting them. You can install the older release on 22.04 with these instructions:
##### Enable Wayland
`sudo nano /etc/gdm3/custom.conf`
Change `WaylandEnable` to `true`:
```
WaylandEnable=true
```
Reboot for this change to take effect.
##### Update udev rules for NVIDIA users
## Building
```shell
sudo nano /usr/lib/udev/rules.d/61-gdm.rules
git clone --recurse-submodules https://github.com/outbackdingo/hyprcosmic
cd hyprcosmic
just build
```
Look for `LABEL="gdm_prefer_xorg"` and `LABEL="gdm_disable_wayland"`. Add `#` to the `RUN` statements so they look like this:
Build dependencies are COSMIC's — see [upstream's list](https://github.com/pop-os/cosmic-epoch#setup-on-distributions-without-packaging-of-cosmic-components),
which is long and distribution-specific. `rustup` is recommended over the
distribution's rustc: `cosmic-comp` is edition 2024 and pins Rust 1.93 in its
`rust-toolchain.toml`, which is newer than several stable distributions ship —
Debian bookworm's rustc is 1.63. `just` is likewise absent before Debian
trixie; `cargo install just --locked` covers it.
```
LABEL="gdm_prefer_xorg"
#RUN+="/usr/libexec/gdm-runtime-config set daemon PreferredDisplayServer xorg"
GOTO="gdm_end"
## Installing
LABEL="gdm_disable_wayland"
#RUN+="/usr/libexec/gdm-runtime-config set daemon WaylandEnable false"
GOTO="gdm_end"
```
Restart gdm
The easiest route is a package. Every tag builds one for Fedora, Arch and Debian
and attaches it to a draft release; `workflow_dispatch` on **Packages** builds
them at any other time and leaves them as run artifacts.
```shell
sudo systemctl restart gdm
sudo dnf install ./hyprcosmic-*.rpm # Fedora
sudo pacman -U ./hyprcosmic-*.pkg.tar.zst # Arch
sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian
```
##### Install COSMIC
Expect this to fail the first time, and read what it says when it does. These
packages provide `/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, so they
**conflict with the distribution's `cosmic-comp` and `cosmic-session`** and your
package manager will refuse until those are removed. That refusal is the design:
installing HyprCosmic replaces the machine's desktop, and it should take a
deliberate `dnf remove cosmic-comp cosmic-session` to say so rather than a
resolver deciding on your behalf. Both session entries survive the swap, so the
greeter still offers a stock COSMIC shell afterwards.
`sudo apt install cosmic-session`
After logging out, click on your user and there will be a sprocket at the bottom right. Change the setting to COSMIC. Proceed to log in.
## Installing on Arch Linux
Install via [cosmic-session](https://archlinux.org/packages/extra/x86_64/cosmic-session/) or the [cosmic](https://archlinux.org/groups/x86_64/cosmic/) group, e.g.:
`pacman -S cosmic-session` or `pacman -S cosmic`
Then log out, click on your user, and a sprocket at the bottom right shows an additional entry alongside your desktop environments. Change to COSMIC and proceed with log in.
For a more detailed discussion, consider the [relevant section in the Arch wiki](https://wiki.archlinux.org/title/COSMIC).
## Installing on Fedora Linux
COSMIC can be installed from the built-in repositories on Fedora 41+:
```
sudo dnf install @cosmic-desktop-environment
```
Alternatively, for more up-to-date COSMIC packages (but less quality control), you can use the nightly COPR builds:
```
sudo dnf copr enable ryanabx/cosmic-epoch && sudo dnf install cosmic-desktop
```
After installing, log out, click on your user, and use the sprocket in the bottom right to select the COSMIC desktop environment before logging in.
For more information, check the [Fedora Wiki COSMIC SIG page](https://fedoraproject.org/wiki/SIGs/COSMIC) or the [COPR page](https://copr.fedorainfracloud.org/coprs/ryanabx/cosmic-epoch/).
## Installing on NixOS
The COSMIC module on NixOS can be enabled by adding the following lines to
your NixOS configuration file (`configuration.nix` or in your Flake):
```nix
{
# Enable the COSMIC login manager
services.displayManager.cosmic-greeter.enable = true;
# Enable the COSMIC desktop environment
services.desktopManager.cosmic.enable = true;
}
```
While some packages like `cosmic-session` might be present in prior versions,
the modules that add full support for COSMIC were added in **NixOS 25.05**.
You can find more details on the [NixOS Wiki](https://wiki.nixos.org/wiki/COSMIC).
## Installing on openSUSE tumbleweed
Cosmic can be installed by adding X11:COSMIC:Factory repo with opi.
```
opi patterns-cosmic
```
Select X11:COSMIC:Factory, after installing keep the repo.
Then log out, click on your user, and a sprocket at the bottom right shows an additional entry alongside your desktop environments. Change to COSMIC and proceed with log in.
For further information, you may check the [OBS page](https://build.opensuse.org/project/show/X11:COSMIC:Factory).
## Installing on Gentoo Linux
COSMIC can be installed on Gentoo via a custom overlay. Add the overlay using your preferred overlay manager (such as eselect), and then install the desktop environment:
`eselect repository add cosmic-overlay git https://github.com/fsvm88/cosmic-overlay.git`
Next, synchronize the repository with
`emaint sync -r cosmic-overlay`
and install the COSMIC desktop environment and its associated themes:
`emerge cosmic-meta pop-theme-meta -av`
Please note that the ebuilds have testing keywords and need to unmasked on stable systems for successful installation.
Then log out, and switch the desktop environment to COSMIC, the procedure depends on your login manager.
For further information, you may check the [Gentoo Wiki](https://wiki.gentoo.org/wiki/COSMIC) or [Overlay Repository](https://github.com/fsvm88/cosmic-overlay).
## Setup on distributions without packaging of COSMIC components
The COSMIC desktop environment requires a few dependencies. The rustc and just packages of your distro may be too old, so we recommend installing rustc and cargo with rustup, and installing just with cargo.
(This list does not try to be exhaustive, but rather tries to provide a decent starting point. For detailed instructions, check out the individual projects):
- [just](https://github.com/casey/just)
- rustc
- cargo
- c compiler (cc)
- make
- git
- libwayland
- mesa (or third-party libEGL/libGL implementations, though interfacing with mesa's libglvnd is generally recommended).
- libseat
- libxkbcommon
- libinput
- udev
- dbus
- libdisplay-info-dev
- libgstreamer1.0-dev
- libgstreamer-plugins-base1.0-dev
optionally (though the build-system might currently require these libraries):
- libsystem
- libpulse
- libexpat1
- libfontconfig
- libfreetype
- lld
- libgbm-dev
- libclang-dev
- libpipewire-0.3-dev
Note: `libfontconfig`, `libfreetype`, and `lld` are packages specific to Linux distributions. You may need to find the equivalent version for your distribution if you are not using Pop!_OS.
The required ones can be installed with:
```
sudo apt install -y \
build-essential \
dbus \
git \
libdbus-1-dev \
libdisplay-info-dev \
libflatpak-dev \
libglvnd-dev \
libgstreamer-plugins-base1.0-dev \
libgstreamer1.0-dev \
libinput-dev \
libpam0g-dev \
libpixman-1-dev \
libseat-dev \
libssl-dev \
libwayland-dev \
libxkbcommon-dev \
rustup \
udev
rustup toolchain install stable
cargo install just
```
and the optional ones with:
```
sudo apt install -y \
libclang-dev \
libexpat1-dev \
libfontconfig-dev \
libfreetype-dev \
libgbm-dev \
libpipewire-0.3-dev \
libpulse-dev \
libsystemd-dev \
lld \
mold
```
They can be installed all at once with:
```
sudo apt install -y \
build-essential \
dbus \
git \
libclang-dev \
libdbus-1-dev \
libdisplay-info-dev \
libexpat1-dev \
libflatpak-dev \
libfontconfig-dev \
libfreetype-dev \
libgbm-dev \
libglvnd-dev \
libgstreamer-plugins-base1.0-dev \
libgstreamer1.0-dev \
libinput-dev \
libpam0g-dev \
libpipewire-0.3-dev \
libpixman-1-dev \
libpulse-dev \
libseat-dev \
libssl-dev \
libsystemd-dev \
libwayland-dev \
libxkbcommon-dev \
lld \
mold \
rustup \
udev
rustup toolchain install stable
cargo install just
```
### Testing
The easiest way to test COSMIC DE currently is by building a systemd system extension (see `man systemd-sysext`).
```
git clone --recurse-submodules https://github.com/pop-os/cosmic-epoch
cd cosmic-epoch
just sysext
```
This will create a system-extension called `cosmic-sysext`, which you can move (without renaming!) into e.g. `/var/lib/extensions`.
After starting systemd-sysext.service (`sudo systemctl enable --now systemd-sysext`) and refreshing (`sudo systemd-sysext refresh`) or rebooting,
COSMIC will be an available option in your favorite display manager.
If you have SELinux enabled (e.g. on Fedora), the installed extension won't have the correct labels applied.
To test COSMIC, you can temporarily disable it and restart `gdm` (note that this will close your running programs).
Building it yourself instead:
```shell
sudo setenforce 0
sudo systemctl restart gdm
sudo just install '' /usr
```
**Note**: An extension created this way will be linked against specific libraries on your system and will not work on other distributions.
It also requires the previously mentioned libraries/dependencies at runtime to be installed in your system (the system extension does not carry these libraries).
The two positional arguments are `rootdir` (a staging root, for packaging) and
`prefix`. **Use `/usr`, not the `/usr/local` default.** Several files name
`/usr/share/hyprcosmic` as a literal because they have no way to interpolate a
prefix — a rofi `.rasi` has no variables, `hyprcosmic.desktop` has no way to
expand one into `Exec=`, and `autostart` is deliberately not a shell.
`install-assets.sh` prints the exact list when you use another prefix.
**Read-Only Filesystem**: If you're not on an immutable distro you may notice that `/usr/` and `/opt/` are read-only.
this is caused by `systemd-sysext` being enabled, when you are done testing you can disable `systemd-sysext` (`sudo systemctl disable --now systemd-sysext`)
To stage instead of install:
It is thus not a proper method for long term deployment.
```shell
just install /tmp/stage /usr
```
### Packaging
This installs all of COSMIC — the 27 unmodified components as well — plus
`cosmic-conf` at `$prefix/bin/cosmic-conf`, the shared waybar and rofi assets
under `$prefix/share/hyprcosmic/`, and `hyprcosmic-powermenu`.
COSMIC DE is packaged for Pop!_OS. For reference, look at the `debian` folders in the projects repositories.
These and the `justfile` inside this repository may be used as references on how to package COSMIC DE, though no backwards-compatibility guarantees are provided at this stage.
`install` depends on `build`, which is upstream's arrangement and means `sudo
just install` compiles as root. That is inherited, not chosen; if you would
rather not, build into a staging root as your own user and copy it into place.
### Versioning
Then log out. `HyprCosmic` appears on the greeter's session menu next to
`COSMIC`; both work.
COSMIC DE is a work in progress with many moving pieces.
We do our best to keep the referenced submodule commits in this repository building and working together; as a consequence, they might not contain the latest updates and features from these repositories (yet).
### Per-user setup
The commits corresponding with the current release are tagged `epoch-X.Y.Z`, where `X` is the major release and the last two numbers denote incremental minor releases. (During development of new major versions, an additional `-alpha.Y.Z` or `-beta.Y.Z` may be appended.)
`just install` places nothing in a home directory — under `sudo` the only home
directory it could see is root's. Four files are yours to place:
COSMIC Epoch version numbers are mainly for the benefit of non-Pop!_OS distributions; Pop!_OS uses its own build system, and typically receives updates to individual submodules before they're tagged as part of a COSMIC Epoch release.
```shell
mkdir -p ~/.config/hyprcosmic/waybar
cp config/cosmic.conf config/autostart ~/.config/hyprcosmic/
cp config/waybar/style.css ~/.config/hyprcosmic/waybar/
```
## Translating
`style.css` is per-user rather than shared for one reason: it `@import`s a
sibling `theme.css` holding the installed HyDE theme's palette, and a relative
`@import` resolves against the importing file. That sibling is written by
`import-theme --assets`, so the bar is unstyled until you have imported a theme.
To submit translations for COSMIC in your language, please use Weblate: https://hosted.weblate.org/projects/pop-os/
The fourth file, `~/.config/rofi/config.rasi`, is written by `import-theme
--assets` too, because it names per-machine paths.
## Contact
- [Mattermost](https://chat.pop-os.org/)
- [Twitter](https://twitter.com/pop_os_official)
- [Instagram](https://www.instagram.com/pop_os_official/)
Runtime dependencies of the shell itself are not COSMIC's and are not built
here: `waybar`, `rofi` (wayland build), `awww` (formerly `swww`), and a Nerd
Font for the bar's glyphs.
## Configuration
`~/.config/hyprcosmic/cosmic.conf`, in Hyprland's idiom, compiled into
`cosmic-config` by:
```shell
cosmic-conf apply # once
cosmic-conf apply --diff # show what would change, write nothing
cosmic-conf watch # recompile on every edit, for the whole session
```
`watch` is the first line of the shipped `autostart`, which is what makes "the
file wins" true at login and not only when you last ran `apply` by hand:
whatever COSMIC's settings UI stored since then is overwritten before the
desktop settles. A malformed edit is reported to the session log and the last
good configuration stays in place, so a typo cannot leave you at a broken
desktop.
The rule is one-way and deliberate. Keys this file names are overwritten from
it on every login; keys it does not name are left entirely alone, so
cosmic-settings remains the right place to change anything the file is silent
about. There is no write-back — the GUI never edits `cosmic.conf`.
`bind` lines go to the Shortcuts `custom` key, which cosmic-comp merges over
`defaults`, so the system defaults file is never touched and reverting is a
matter of deleting the lines and re-applying. Hyprland spellings and COSMIC
spellings are both accepted for the same setting (`input:follow_mouse` and
`general:focus_follows_cursor`), and the last assignment wins. Where a Hyprland
value has no COSMIC equivalent — `follow_mouse = 2` and `3`, which separate
pointer focus from keyboard focus — it is rejected with an explanation rather
than quietly rounded.
What the shipped file sets up, since the components those keys used to reach are
no longer running:
| Binding | Does |
| --- | --- |
| `Super` (tap), `Super+/`, `Super+A` | `rofi -show drun` |
| `Super+W` | `rofi -show window`, in place of the workspace overview |
| `Super+Return` | `cosmic-term` (`Super+T` still works — cosmic-comp handles that one itself) |
| `Super+Shift+E` | `hyprcosmic-powermenu`: lock, suspend, log out, reboot, shut down |
The power menu is there because cosmic-panel hosts COSMIC's power applet, and
without the panel a session had no way out short of `systemctl reboot` from a
terminal. The same script backs waybar's power button, so the two cannot drift
apart, and it confirms before anything that ends the session.
See [`config/cosmic.conf`](config/cosmic.conf); it is commented at length and is
the reference for what is supported.
## Theming
```shell
cosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \
--out ~/.config/hyprcosmic/theme.conf --report --assets
```
This translates a HyDE theme into conf keys, and with `--assets` also installs
the wallpapers, GTK and icon themes, and the waybar/rofi/kitty theme files that
sit beside `hypr.theme`. `--report` prints everything that did not translate
cleanly, which is the honest half of the output.
`theme.conf` is written as a separate file and `source`d from `cosmic.conf`
rather than pasted into it. That keeps re-importing from touching your
keybindings, and anything you want to override can simply be repeated later in
`cosmic.conf`, since the last assignment to a key wins. The `source` line ships
commented out — a `source` naming a file that does not exist is a hard error,
and no theme is imported on a fresh install. Uncomment it once you have run the
command above; `import-theme` says so as well.
Change the wallpaper by repointing the `current` symlink that `--assets`
maintains, not by editing `autostart`:
```shell
ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
~/.local/share/wallpapers/hyprcosmic/current
```
## Continuous integration
Two workflows, on purpose:
- `.github/workflows/ci.yml` is upstream's, unmodified. It builds the entire
desktop on Arch via `just sysext`, which is exactly the check a meta-repo
wants and is not made less useful by forking.
- `.github/workflows/hyprcosmic.yml` covers what upstream's does not:
`cosmic-conf` built, tested and clippy-clean on Fedora, Debian and Arch; a
check that the shipped `cosmic.conf` still parses and resolves against the
current schema; that `config/waybar/config.jsonc` is still in step with the
generator that produces it; that the template and generator stay pure ASCII;
and an `install-assets.sh` round trip into a staging root, verified with
`--check`.
The two forks carry a `hyprcosmic.yml` of the same shape, each building on
Fedora, Debian and Arch and asserting that its install landed at upstream's
paths — and that nothing landed in the private `/usr/libexec/hyprcosmic/` this
fork used to use, which is the assertion that would otherwise rot quietly.
`packages.yml` in this repository builds installable packages for the same three
distributions: an RPM, a `.pkg.tar.zst` and a `.deb`, each compiled inside a
container of the distribution it targets so the sonames it records are the ones
the installing machine will have. It runs on tags and on demand, not on every
push — three full desktop builds is hours of runner time. Tags additionally open
a **draft** release with the packages attached; drafts rather than published,
because installing one of these replaces the machine's desktop.
The waybar generator deserves its own note. `config.jsonc` is generated from
`config.jsonc.in` and a codepoint table in `generate-config.py`, and is never
hand-edited: Nerd Font glyphs live in the Private Use Area, where they are
destroyed by being retyped and indistinguishable from each other in a diff. CI
regenerates the file and fails if it moves.
## Known gaps
- The `/usr/share/hyprcosmic` literals described under [Installing](#installing).
- `docs/unreproducible-dead-input-2026-08-10.md` records a session that came up
without input and has not been reproduced since. It is written down rather
than closed.
## Trademark
COSMIC is a System76 trademark. This fork is not affiliated with or endorsed by
System76. See [TRADEMARK.md](TRADEMARK.md), which is upstream's policy and
applies here.
## Upstream
For COSMIC itself — the component list, packaging status, translations, and how
to install it on your distribution rather than building it — see
[pop-os/cosmic-epoch](https://github.com/pop-os/cosmic-epoch).
+96
View File
@@ -0,0 +1,96 @@
# Programs the HyprCosmic session starts after COSMIC's own components.
#
# Installed to ~/.config/hyprcosmic/autostart and read by cosmic-session's
# profile module. One command per line. Arguments and quoting work, but this is
# NOT a shell: no $VAR, no ~, no globs, no $(...). Paths must be absolute.
#
# `#` starts a comment where a word would start, so `--color=#1a1b26` is fine
# but `--color #1a1b26` is not; quote it as '#1a1b26' if you need the latter.
# Keep cosmic-config in step with cosmic.conf for the whole session: compile
# once at login, then again on every edit to it or to anything it sources.
#
# First in the file because its startup pass is what makes "the file wins" true
# at login rather than only when you last ran `apply` by hand -- whatever
# COSMIC's settings UI stored since then is overwritten before the desktop
# settles. The bar does not read cosmic-config, so the ordering is for the
# compositor's benefit, not waybar's.
#
# No --config: the default is derived from XDG_CONFIG_HOME (or HOME) inside the
# process, so unlike the waybar line below this needs no shell to find a home
# directory for it.
#
# A malformed edit is not fatal. It is reported to the session log and the last
# good configuration stays in place, so a typo cannot leave you at a broken
# desktop -- fix the file and the next save applies.
cosmic-conf watch
# The bar. The layout is shared and lives under /usr/share, but the stylesheet
# has to be per-user: it imports a sibling theme.css holding the installed HyDE
# theme's palette, and a relative @import resolves against the importing file.
#
# So the style path has to name a home directory, and this file is not a shell:
# `~` and `$HOME` on a bare command line here are literal text, not expansions.
# `sh -c` is what gets them expanded, for the same reason and under the same
# terms as the wallpaper line below -- naming `sh` is naming a program, which
# this file was always allowed to do.
#
# `exec` matters. Without it sh stays in the process tree as waybar's parent,
# and cosmic-session would be supervising the shell rather than the bar: a
# waybar that died would leave sh alive, so the restart that should have
# happened never would.
sh -c 'exec waybar -c /usr/share/hyprcosmic/waybar/config.jsonc -s "$HOME/.config/hyprcosmic/waybar/style.css"'
# rofi is not a daemon. It is launched on demand by a keybinding, which COSMIC
# stores in com.system76.CosmicSettings.Shortcuts rather than here. Set those
# with `bind` lines in cosmic.conf and `cosmic-conf apply`; see config/cosmic.conf.
# Wallpaper. The hyprcosmic profile does not start cosmic-bg, so without this
# there is nothing drawing a background at all.
#
# HyDE calls this swww; upstream renamed it to awww at 0.12 and the Fedora
# package Obsoletes swww < 0.12.0. /usr/bin/swww is a shim that prints a
# deprecation warning and is documented as going away, so use the real name.
# Packaged in the alebastr/sway-extras COPR.
#
# The daemon only holds the surface -- it shows nothing until an image is set.
awww-daemon
# ...which is what this does. Without it the daemon runs, draws nothing, and you
# get a blank screen below the bar with no error anywhere: the failure is that
# nobody asked for a wallpaper, so nothing reports one missing.
#
# `sh -c` rather than a bare `awww img`, for one reason: awww-daemon above has
# only just been forked and is not listening yet, so an immediate `awww img`
# loses a race and fails silently. The loop waits for the daemon to answer
# before setting the image.
#
# This does not weaken the no-shell rule in the header. That rule exists so a
# file naming programs cannot be escalated into arbitrary execution; naming
# `sh` explicitly is just naming a program, and anyone able to write this file
# could already name any binary on the system.
#
# `current` is a symlink `cosmic-conf import-theme --assets` maintains beside
# the wallpapers it copies, pointing at one of them. It is named here rather
# than a real file so that this line and rofi's local.rasi -- which shows the
# same image in the launcher's sidebar -- cannot drift apart, and so that
# importing a different theme does not leave this pointing at a path that no
# longer exists.
#
# Change the wallpaper by repointing the link, not by editing this file:
#
# ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
# ~/.local/share/wallpapers/hyprcosmic/current
sh -c 'until awww query >/dev/null 2>&1; do sleep 0.2; done; exec awww img "$HOME/.local/share/wallpapers/hyprcosmic/current"'
# A terminal, unconditionally, as the way back in.
#
# Everything above assumes the keybindings work. If they do not -- a bad
# `bind` line, a shortcut COSMIC declines to register, a compositor that came
# up without input -- then there is no way to launch rofi, no way to launch a
# terminal, and the only remaining option is a VT switch. Starting one terminal
# at login costs a window you can close and removes that entire failure class.
#
# Last in the file so it is the most recently mapped window, and therefore on
# top of anything else that opened during startup.
cosmic-term
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/bash
#
# Power menu for a HyprCosmic session: lock, suspend, log out, reboot, shut down.
#
# WHY THIS EXISTS
# ---------------
# Stock COSMIC puts all of these behind the power applet in cosmic-panel.
# HyprCosmic replaces the panel with waybar, so that applet never starts and the
# session had no exit at all -- not even a logout -- short of `systemctl reboot`
# typed into a terminal. This is the exit. It is reached from a keybinding and
# from the bar, and both run this same script so the two can never disagree.
#
# Logging out goes through cosmic-session's own D-Bus interface rather than
# killing anything. That is the method the panel applet called, and it is the
# only one that lets the session stop its clients in order instead of pulling
# the compositor out from under them; see `com.system76.CosmicSession.Exit` in
# cosmic-session/src/service.rs.
#
# Reboot and poweroff go to systemd directly. polkit already authorises both for
# an active local session without a prompt, which is why no pkexec is involved.
#
# NO GLYPHS HERE, DELIBERATELY
# ----------------------------
# Every entry is a plain word. Nerd Font icons live in the Private Use Area,
# where they are indistinguishable from each other in a diff and are silently
# destroyed by anything that retypes rather than copies them. waybar's power
# icon is the only glyph in this feature and it comes from generate-config.py,
# which checks each codepoint against the installed font.
set -uo pipefail
SESSION_DEST=com.system76.CosmicSession
SESSION_PATH=/com/system76/CosmicSession
command -v rofi >/dev/null 2>&1 || {
printf 'hyprcosmic-powermenu: rofi is not installed\n' >&2
exit 1
}
# Errors go to a rofi dialog, not just stderr. The two ways in are a keybinding
# and a bar click, and neither has a terminal attached to read stderr from.
fail() {
printf 'hyprcosmic-powermenu: %s\n' "$*" >&2
rofi -e "$*" >/dev/null 2>&1 || :
exit 1
}
# No theme arguments, so ~/.config/rofi/config.rasi applies and this matches the
# launcher. Icons off because these entries have none and the reserved space
# would sit there empty.
#
# -no-custom is what stops a typed line from being returned as if it were a
# choice: without it, Return on an empty filter hands back whatever was typed,
# and the case below would fall through to no branch at all. With it, anything
# that is not one of the offered entries is refused.
menu() {
local prompt="$1"
shift
printf '%s\n' "$@" | rofi -dmenu -i -no-custom -no-show-icons -p "$prompt"
}
# Only for the three that end the session. Locking and suspending undo
# themselves with a keypress, so a confirmation there is pure friction; logging
# out, rebooting and shutting down each throw away every unsaved thing on the
# desktop, and this menu is one keystroke away at all times.
#
# "No" is listed first so that it is the selected row when the dialog opens.
confirm() {
local answer
answer="$(menu "$1?" "No" "Yes")" || return 1
[[ "$answer" == "Yes" ]]
}
logout() {
command -v busctl >/dev/null 2>&1 ||
fail "busctl is not installed, so the session cannot be asked to exit"
busctl --user call "$SESSION_DEST" "$SESSION_PATH" "$SESSION_DEST" Exit && return 0
# Reaching here means cosmic-session is not answering on the bus. Say so
# rather than silently doing nothing: being unable to leave the session is
# the exact problem this script was written for, so a dead end here is
# worse than a blunt instrument. loginctl is that blunt instrument, and it
# is named explicitly so the choice to use it is the user's.
fail "cosmic-session did not answer on D-Bus.
To force the session to end, run:
loginctl terminate-session ${XDG_SESSION_ID:-\$XDG_SESSION_ID}"
}
choice="$(menu "Power" "Lock" "Suspend" "Log out" "Reboot" "Shut down")" || exit 0
case "$choice" in
"Lock") exec loginctl lock-session ;;
"Suspend") exec systemctl suspend ;;
"Log out") confirm "Log out" && logout ;;
"Reboot") confirm "Reboot" && exec systemctl reboot ;;
"Shut down") confirm "Shut down" && exec systemctl poweroff ;;
"") ;;
*) fail "unrecognised choice: ${choice}" ;;
esac
# Reached only by answering "No" to a confirmation, or dismissing it. The four
# branches that act replace this process outright, and `fail` exits on its own,
# so nothing else arrives here -- and changing your mind is not a failure.
exit 0
+113
View File
@@ -0,0 +1,113 @@
# HyprCosmic configuration, in Hyprland's idiom.
#
# Compiled into COSMIC's config tree by `cosmic-conf apply`. The file wins:
# every key here overwrites whatever COSMIC's own settings UI last stored, so
# edit this rather than the GUI for anything it covers.
# --- Theme --------------------------------------------------------------
#
# `cosmic-conf import-theme <theme>/hypr.theme --out ~/.config/hyprcosmic/theme.conf`
# turns a HyDE theme into conf keys. Sourcing it rather than pasting it in
# keeps the two apart: re-importing overwrites theme.conf and cannot touch the
# keybindings below, and anything you want to override can simply be repeated
# later in this file, since the last assignment to a key wins.
#
# Commented out because a `source` pointing at a file that does not exist is a
# hard error, and no theme is imported yet. Uncomment it once you have run the
# command above -- import-theme will remind you.
#
# source = ~/.config/hyprcosmic/theme.conf
$mainMod = SUPER
# --- Tiling -------------------------------------------------------------
#
# COSMIC ships with autotile off, so a new window opens floating, at whatever
# size the application asked for, on top of what you were already looking at.
# This is the setting that makes window placement automatic: each new window
# takes a share of the screen instead.
#
# It applies immediately and everywhere. `autotile_behavior` is a separate
# COSMIC key that defaults to Global, which retiles workspaces that already
# exist; the other value, PerWorkspace, arms only workspaces created from now
# on. There is no conf key for it because the default is the one worth having
# -- if you want the other, set it in cosmic-settings and this file will not
# fight you over it.
#
# Super+Y still toggles tiling for the current workspace on its own, so a
# workspace you want to keep floating does not need this turned off.
#
# Gaps are here rather than in the theme block because they are only visible
# once windows tile: with autotile off nothing is laid out, so nothing has a
# gap. gaps_out is doubled so the screen edge reads as deliberate margin
# rather than as one more seam.
$gap = 4
general {
autotile = true
preserve_split = true
gaps_in = $gap
gaps_out = $gap * 2
}
# --- Input ---------------------------------------------------------------
#
# Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland
# spells it `input:follow_mouse`, and that spelling is what this file accepts;
# `general:focus_follows_cursor` is the same setting under COSMIC's own name,
# and setting both is not an error -- whichever comes last in the file wins.
#
# Autoraise comes with it and is not a separate key. cosmic-comp raises a
# window as part of focusing it, so a floating window under the pointer comes
# to the front on its own. Tiled windows do not overlap, so there is nothing
# there to raise.
#
# The delay is what stops the focus from skating across every window between
# where the pointer started and where it stopped -- moving the mouse to a menu
# on the far side of the screen should not hand focus to whatever it crossed on
# the way. 250ms is COSMIC's own default and is kept rather than shortened,
# because the failure it prevents is more annoying than the wait.
#
# Only 0 and 1 mean anything here. Hyprland's 2 and 3 separate pointer focus
# from keyboard focus, which cosmic-comp cannot do -- it has one focus. Those
# values are rejected with an explanation rather than rounded to 1.
input {
follow_mouse = 1
follow_mouse_delay = 250
}
# --- Launcher -----------------------------------------------------------
#
# The hyprcosmic profile does not start cosmic-launcher or cosmic-app-library,
# which leaves COSMIC's stock Super, Super+/ and Super+A bindings pointing at
# nothing. These take them over with rofi. Written to the Shortcuts `custom`
# key, which cosmic-comp merges over `defaults`, so the system file is not
# touched and reverting is a matter of deleting these lines and re-applying.
# Tap Super on its own to open the launcher.
# The key field is deliberately empty: COSMIC supports
# modifier-only bindings, which Hyprland's `bind` cannot express.
bind = $mainMod, , exec, rofi -show drun
bind = $mainMod, slash, exec, rofi -show drun
bind = $mainMod, A, exec, rofi -show drun
# Was System(WorkspaceOverview); cosmic-workspaces is not running either.
# rofi's window mode is the nearest thing that still shows every open window.
bind = $mainMod, W, exec, rofi -show window
# Terminal, in the Hyprland idiom. Super+T also still works — cosmic-comp
# handles System(Terminal) itself, so that binding never went dead.
bind = $mainMod, Return, exec, cosmic-term
# --- Session ------------------------------------------------------------
#
# The hyprcosmic profile disables cosmic-panel, and COSMIC's power applet lives
# in that panel, so a HyprCosmic session had no logout, reboot or shutdown
# anywhere -- the only way out was `systemctl reboot` from a terminal. This is
# that way out. The same script backs waybar's power button, so the two cannot
# drift apart, and it asks for confirmation before anything that ends the
# session.
#
# Super+Shift+E is Hyprland's own spelling for "exit". The menu also offers
# lock and suspend, which is why the binding is not named after logout.
bind = $mainMod SHIFT, E, exec, hyprcosmic-powermenu
+50
View File
@@ -0,0 +1,50 @@
/* rofi configuration and theme entry point for a HyprCosmic session.
*
* Below the configuration block this file is only an import list, because the
* order is the whole design:
*
* 1. palette.rasi default colours, under the names HyDE themes use
* 2. theme.rasi the installed HyDE theme's rofi.theme, if any
* 3. rules.rasi geometry and layout; no colour literals at all
* 4. local.rasi per-machine bits: the sidebar wallpaper, the icon theme
*
* Imports are read strictly in sequence and a later definition of a property
* wins over an earlier one. That is what lets step 2 recolour the launcher
* without step 3 knowing a theme exists, and step 4 point at a wallpaper
* without either of them knowing the path.
*
* There is no bridge step, unlike waybar: a HyDE rofi.theme defines exactly the
* names rules.rasi already references, so nothing needs mapping.
*
* Steps 2 and 4 are relative imports, which rofi resolves against the directory
* of the importing file. That is why this file belongs at
* ~/.config/rofi/config.rasi rather than under /usr/share: both are per-user.
* It is also the only name rofi loads on its own, so `rofi -show drun` from a
* keybinding picks all of this up with no arguments and no launcher script.
*
* Both relative imports must exist. A missing @import is an error rofi reports
* in place of the launcher, not a warning it skips, so `cosmic-conf` writes
* theme.rasi and local.rasi even when there is nothing to put in them -- empty,
* in which case the defaults from step 1 stand.
*/
configuration {
modi: "drun,run,window,filebrowser";
show-icons: true;
/* Nerd Font glyphs, as HyDE's style_1 has them: apps, terminal, files,
* windows. They render as tofu without a Nerd Font installed; see the font
* list in rules.rasi. */
display-drun: " ";
display-run: " ";
display-filebrowser: " ";
display-window: " ";
drun-display-format: "{name}";
window-format: "{w}{t}";
}
@import "/usr/share/hyprcosmic/rofi/palette.rasi"
@import "theme.rasi"
@import "/usr/share/hyprcosmic/rofi/rules.rasi"
@import "local.rasi"
+31
View File
@@ -0,0 +1,31 @@
/* Default colours for the HyprCosmic launcher.
*
* Only one name set is needed here, unlike waybar's palette.css. A HyDE theme's
* rofi.theme is a list of exactly these names -- main-bg, select-fg and so on --
* and rules.rasi references the same names directly, so there is nothing to
* bridge between. Importing a theme after this file simply re-points them.
*
* Defining them here is what makes a missing theme harmless: rules.rasi always
* resolves against something, so an unthemed launcher renders with these values
* instead of every widget falling back to rofi's stock grey.
*
* Values are Tokyo Night, matching cosmic-conf's own defaults.
*/
* {
/* Window and text. main-br is the window border, main-ex an accent HyDE
* uses for secondary text; rofi themes vary in how much of it they set. */
main-bg: #1a1b26e6;
main-fg: #c0caf5ff;
main-br: #bb9af7ff;
main-ex: #7dcfffff;
/* The highlighted row. */
select-bg: #7aa2f7ff;
select-fg: #1a1b26ff;
/* Named by HyDE's themes but unused by this layout. Defined so a theme that
* sets them parses, and so one that does not still resolves. */
separatorcolor: transparent;
border-color: transparent;
}
+173
View File
@@ -0,0 +1,173 @@
/* Geometry and layout for the HyprCosmic launcher.
*
* Adapted from HyDE's style_1 ("Background image with a sidebar list"), which
* is the layout its rofilaunch.sh uses by default. Two deliberate differences:
*
* - HyDE injects the border radii, border width and font at the command line,
* as three -theme-str arguments computed from Hyprland's own gaps and
* border settings. We have no launcher script -- the keybinding in
* cosmic.conf runs plain `rofi -show drun` -- so those values are written
* out here, at the defaults rofilaunch.sh would have produced.
* - The sidebar image lives in local.rasi rather than here, because the
* wallpaper path is per-user. HyDE points at ~/.cache/hyde/wall.thmb, a
* thumbnail its wallpaper scripts generate; we do not run those, so
* pointing there would render an empty panel on every machine.
*
* No colour literals: every colour is a name from palette.rasi, which a theme
* may have re-pointed. That separation is what lets `cosmic-conf import-theme`
* recolour the launcher without touching a line of layout.
*/
/* Pango takes a comma-separated family list, so the Nerd Font is a preference
* rather than a requirement -- the glyphs in config.rasi's mode labels need it,
* but everything else still renders without it. */
* {
font: "JetBrainsMono Nerd Font, Noto Sans Mono 10";
}
// Main //
window {
height: 33em;
width: 63em;
transparency: "real";
fullscreen: false;
enabled: true;
cursor: "default";
spacing: 0em;
padding: 0em;
border: 2px;
border-radius: 30px;
border-color: @main-br;
background-color: @main-bg;
}
mainbox {
enabled: true;
spacing: 0em;
padding: 0em;
orientation: horizontal;
children: [ "dummywall" , "listbox" ];
background-color: transparent;
}
/* The sidebar. Solid colour here so that a machine with no wallpaper override
* gets a panel in the theme's own background, not a hole. local.rasi paints an
* image over the top when there is one. */
dummywall {
spacing: 0em;
padding: 0em;
width: 37em;
expand: false;
orientation: horizontal;
children: [ "mode-switcher" , "inputbar" ];
background-color: @main-bg;
}
// Modes //
mode-switcher {
orientation: vertical;
enabled: true;
width: 3.8em;
padding: 9.2em 0.5em 9.2em 0.5em;
spacing: 1.2em;
background-color: transparent;
}
button {
cursor: pointer;
border-radius: 2em;
background-color: @main-bg;
text-color: @main-fg;
}
button selected {
background-color: @main-fg;
text-color: @main-bg;
}
// Inputs //
/* The entry is hidden: typing filters the list without a visible prompt, which
* is what gives style_1 its uncluttered look. inputbar still has to exist for
* keystrokes to reach the filter. */
inputbar {
enabled: true;
children: [ "entry" ];
background-color: transparent;
}
entry {
enabled: false;
}
// Lists //
listbox {
spacing: 0em;
padding: 2em;
children: [ "dummy" , "listview" , "dummy" ];
background-color: transparent;
}
listview {
enabled: true;
spacing: 0em;
padding: 0em;
columns: 1;
lines: 8;
cycle: true;
dynamic: true;
scrollbar: false;
layout: vertical;
reverse: false;
expand: false;
fixed-height: true;
fixed-columns: true;
cursor: "default";
background-color: transparent;
text-color: @main-fg;
}
dummy {
background-color: transparent;
}
// Elements //
element {
enabled: true;
spacing: 0.8em;
padding: 0.4em 0.4em 0.4em 1.5em;
border-radius: 20px;
cursor: pointer;
background-color: transparent;
text-color: @main-fg;
}
element selected.normal {
background-color: @select-bg;
text-color: @select-fg;
}
element-icon {
size: 2.8em;
cursor: inherit;
background-color: transparent;
text-color: inherit;
}
element-text {
vertical-align: 0.5;
horizontal-align: 0.0;
cursor: inherit;
background-color: transparent;
text-color: inherit;
}
// Error message //
error-message {
text-color: @main-fg;
background-color: @main-bg;
text-transform: capitalize;
children: [ "textbox" ];
}
textbox {
text-color: inherit;
background-color: inherit;
vertical-align: 0.5;
horizontal-align: 0.5;
}
+36
View File
@@ -0,0 +1,36 @@
/* Map HyDE's waybar colour names onto the ones rules.css uses.
*
* Imported after both palette.css and the theme's own waybar.theme, so it sees
* whichever definition of each HyDE name is in force and does not care which
* file supplied it. This indirection is the whole reason a HyDE theme can
* recolour this bar without any of the rules changing.
*/
@define-color bar-fg @main-fg;
@define-color accent @wb-act-bg;
@define-color muted @wb-hvr-fg;
/* `bar-bg` IS remapped, and this is a deliberate departure from the theme.
*
* HyDE themes set bar-bg to something like rgba(0, 0, 0, 0.1) and rely on the
* compositor blurring whatever is behind the bar. cosmic-comp has no
* rule-driven blur -- `import-theme --report` lists decoration.blur.* as
* needing a compositor patch -- so honouring that value literally gives a
* ~90% transparent bar with unblurred desktop showing through and text that
* cannot be read.
*
* So the theme's own background colour is used at an opacity that works
* without blur. Delete these two lines to get the theme's literal value back,
* or once blur lands.
*/
@define-color bar-bg alpha(@main-bg, 0.85);
/* The pill behind each module. Derived rather than themed: HyDE has no name
* for it, because in HyDE the pill IS the bar -- its modules sit on a
* transparent strip and the compositor blurs the desktop behind them. Without
* blur that reads as text floating on the wallpaper, so here the bar keeps a
* background and the pills are a light lift off it.
*
* Derived from the foreground, not the background, so it stays visible whether
* the theme is dark or light. */
@define-color module-bg alpha(@main-fg, 0.08);
+289
View File
@@ -0,0 +1,289 @@
// Waybar configuration for a HyprCosmic session.
//
// Waybar's shipped default (/etc/xdg/waybar/config.jsonc) is built entirely
// around sway/* modules and shows nothing at all under cosmic-comp, so this
// exists rather than a handful of overrides.
//
// Module choices are constrained by what cosmic-comp actually advertises on the
// Wayland registry, which was checked against a live session:
//
// ext/workspaces ext_workspace_manager_v1 -- advertised by stock cosmic-comp.
// wlr/taskbar zwlr_foreign_toplevel_management_v1 -- NOT in stock
// cosmic-comp; provided by the HyprCosmic fork's Patch A.
// Under a stock compositor this module stays empty and the
// rest of the bar still works.
// tray Needs a StatusNotifierWatcher. cosmic-panel normally
// supplies one via cosmic-applet-status-area, and the
// hyprcosmic profile disables the panel, so waybar hosts the
// watcher itself here.
//
// ICONS. Every glyph below is a \uXXXX escape, never a literal character, and
// this file is generated from config.jsonc.in for exactly that reason:
//
// - These are Private Use Area codepoints. They survive being copied, but
// anything that re-types rather than copies them silently flattens them to
// spaces or drops them next to a neighbour, and the damage is invisible in
// a diff and indistinguishable from a font problem at runtime.
// - An escape is plain ASCII, so it cannot be damaged that way, and it names
// the intended glyph instead of leaving a box you have to identify.
//
// Every codepoint was checked against the installed JetBrainsMono Nerd Font
// with `fc-list :charset=<cp>` before use. That check is worth doing rather
// than copying HyDE's values: HyDE's pulseaudio module uses U+FA80 for the
// muted state, an old Material Design Icons codepoint that Nerd Fonts v3
// moved. It is absent from the installed font and would render as tofu, so
// U+F075F is used instead.
//
// NOT INCLUDED, deliberately:
//
// backlight The only device is acpi_video0, a firmware stub reporting
// brightness 49 of max 49 -- permanently 100% -- whose sysfs
// node is not writable by this user, with brightnessctl absent.
// The module would render a readout that never changes and a
// scroll action that never works: visible, plausible, and dead.
// That is the same failure class as the `pavucontrol` click this
// file used to carry, and it is not worth re-introducing for a
// percentage that is always the same number.
{
"layer": "top",
"position": "top",
"height": 34,
"spacing": 4,
"modules-left": ["ext/workspaces", "wlr/taskbar"],
"modules-center": ["mpris", "clock", "privacy"],
"modules-right": [
"idle_inhibitor",
"custom/swaync",
"bluetooth",
"pulseaudio",
"network",
"temperature",
"cpu",
"memory",
"power-profiles-daemon",
"battery",
"tray",
"custom/power"
],
"ext/workspaces": {
"format": "{name}",
"on-click": "activate"
},
"wlr/taskbar": {
"format": "{icon}",
"icon-size": 18,
"tooltip-format": "{title}",
"on-click": "activate",
"on-click-middle": "close"
},
// Renders nothing at all when no player is running, which is what we want
// from a centre module: it takes no space until there is something to say.
// playerctl backs every action here and is installed.
"mpris": {
"format": "{player_icon} {dynamic}",
"format-paused": "{status_icon} <i>{dynamic}</i>",
"dynamic-order": ["title", "artist"],
"dynamic-separator": " ",
"max-length": 40,
"interval": 1,
"player-icons": { "default": "\uf001" },
"status-icons": {
"playing": "\uf04b",
"paused": "\uf04c",
"stopped": "\uf04d"
},
"on-click": "playerctl play-pause",
"on-click-middle": "playerctl previous",
"on-click-right": "playerctl next",
"on-scroll-up": "playerctl position 5+",
"on-scroll-down": "playerctl position 5-",
"tooltip-format": "{title}\nby {artist}\n{position} / {length}\n\nplayer: {player}"
},
"clock": {
"format": "{:%a %d %b %H:%M}",
"tooltip-format": "<tt><small>{calendar}</small></tt>"
},
// Only appears while something is actually capturing. Nothing is ignored:
// an exception list is how a privacy indicator stops being one.
"privacy": {
"icon-spacing": 6,
"transition-duration": 200,
"modules": [
{ "type": "screenshare", "tooltip": true },
{ "type": "audio-in", "tooltip": true }
]
},
"idle_inhibitor": {
"format": "{icon}",
"format-icons": {
"activated": "\udb80\udd76",
"deactivated": "\udb81\udeca"
},
"tooltip-format-activated": "Caffeine on -- idling and blanking suppressed",
"tooltip-format-deactivated": "Caffeine off -- normal power settings apply"
},
// swaync is this session's notification daemon: it owns
// org.freedesktop.Notifications and is D-Bus activated through
// swaync.service, so it needs no autostart entry and this module starts it
// on first contact if nothing else has.
//
// It is here because the hyprcosmic profile disables cosmic-panel and
// COSMIC's notification applet lives in that panel. Without this there is
// no way to reach notification history at all.
"custom/swaync": {
"format": "{icon}",
"format-icons": {
"none": "\uf0f3",
"notification": "\udb80\udc9a",
"dnd-none": "\uf1f6",
"dnd-notification": "\uf1f6",
"inhibited-none": "\uf0f3",
"inhibited-notification": "\udb80\udc9a",
"dnd-inhibited-none": "\uf1f6",
"dnd-inhibited-notification": "\uf1f6"
},
"return-type": "json",
"exec": "swaync-client -swb",
"on-click": "swaync-client -t -sw",
"on-click-right": "swaync-client -d -sw",
"tooltip": true,
"escape": true
},
// bluez is running with an unblocked hci0. The click target is COSMIC's own
// settings page rather than blueman or overskride, neither of which is
// installed here.
"bluetooth": {
"format": "\uf293",
"format-disabled": "\uf294",
"format-off": "\udb80\udcb2",
"format-connected": "\uf293 {num_connections}",
"tooltip-format": "{controller_alias}\n{num_connections} connected",
"tooltip-format-connected": "{controller_alias}\n\n{device_enumerate}",
"tooltip-format-enumerate-connected": "{device_alias}",
"on-click": "cosmic-settings bluetooth"
},
// The click used to be `pavucontrol`, which is not installed here: a button
// that looked live and did nothing. cosmic-settings ships with the desktop,
// so it cannot go missing the same way. Scroll and middle-click need no
// helper -- waybar changes volume itself and wpctl comes with pipewire.
"pulseaudio": {
"format": "{icon} {volume}%",
"format-muted": "\udb81\udf5f",
"format-bluetooth": "{icon} {volume}%",
"format-bluetooth-muted": "\udb81\udf5f",
"format-icons": {
"headphone": "\uf025",
"headset": "\uf025",
"hands-free": "\uf025",
"phone": "\uf095",
"car": "\uf1b9",
"default": ["\uf026", "\uf027", "\uf028"]
},
"scroll-step": 5,
"tooltip-format": "{desc}\n{icon} {volume}%",
"on-click": "cosmic-settings sound",
"on-click-middle": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
},
"network": {
"format-wifi": "\uf1eb {signalStrength}%",
"format-ethernet": "\udb80\ude00 wired",
"format-linked": "\udb80\ude00 {ifname}",
"format-disconnected": "\udb82\udd2f offline",
"tooltip-format": "{ifname}: {ipaddr}",
"tooltip-format-wifi": "{essid} ({signalStrength}%)\n{ifname}: {ipaddr}",
"on-click": "cosmic-settings network"
},
// Tctl from k10temp, not thermal_zone0. The two read within a degree of
// each other here, but the hwmon path names the CPU sensor explicitly.
//
// It is given as the PCI device's hwmon directory rather than
// /sys/class/hwmon/hwmon6 because hwmonN numbering is assigned in probe
// order and is not stable across boots. The PCI address is.
"temperature": {
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:18.3/hwmon",
"input-filename": "temp1_input",
"format": "\uf2ca {temperatureC} C",
"critical-threshold": 90,
"interval": 5,
"tooltip-format": "CPU package: {temperatureC} C"
},
"cpu": {
"format": "\udb80\udf5b {usage}%",
"interval": 5
},
"memory": {
"format": "\udb83\udee0 {percentage}%",
"interval": 5,
"tooltip-format": "{used:0.1f}G of {total:0.1f}G"
},
// Backed by tuned-ppd, which owns net.hadess.PowerProfiles on this system.
"power-profiles-daemon": {
"format": "{icon}",
"format-icons": {
"default": "\uf0e7",
"performance": "\uf135",
"balanced": "\uf24e",
"power-saver": "\uf06c"
},
"tooltip-format": "power profile: {profile}",
"tooltip": true
},
"battery": {
"states": { "warning": 30, "critical": 15 },
"format": "{icon} {capacity}%",
"format-charging": "\uf1e6 {capacity}%",
"format-plugged": "\uf1e6 {capacity}%",
"format-icons": [
"\udb80\udc8e",
"\udb80\udc7a",
"\udb80\udc7b",
"\udb80\udc7c",
"\udb80\udc7d",
"\udb80\udc7e",
"\udb80\udc7f",
"\udb80\udc80",
"\udb80\udc81",
"\udb80\udc82",
"\udb80\udc79"
],
"tooltip-format": "{timeTo}\n{power:0.1f}W",
"on-click": "cosmic-settings power"
},
"tray": { "icon-size": 18, "spacing": 8 },
// Last on the bar, because it is the one button here that can end the
// session or the uptime and the far corner is the hardest place to hit by
// accident.
//
// It is here for the same reason "custom/swaync" is: the hyprcosmic profile
// disables cosmic-panel, and COSMIC's power applet lives in that panel.
// Without this there is no logout, reboot or shutdown anywhere in the
// session -- the only way out was `systemctl reboot` from a terminal.
//
// The click runs the same script as the $mainMod SHIFT E binding rather
// than calling systemctl here, so the confirmation step cannot be bypassed
// by using the mouse.
"custom/power": {
"format": "\uf011",
"tooltip": true,
"tooltip-format": "Lock, suspend, log out, reboot or shut down",
"on-click": "hyprcosmic-powermenu"
}
}
+289
View File
@@ -0,0 +1,289 @@
// Waybar configuration for a HyprCosmic session.
//
// Waybar's shipped default (/etc/xdg/waybar/config.jsonc) is built entirely
// around sway/* modules and shows nothing at all under cosmic-comp, so this
// exists rather than a handful of overrides.
//
// Module choices are constrained by what cosmic-comp actually advertises on the
// Wayland registry, which was checked against a live session:
//
// ext/workspaces ext_workspace_manager_v1 -- advertised by stock cosmic-comp.
// wlr/taskbar zwlr_foreign_toplevel_management_v1 -- NOT in stock
// cosmic-comp; provided by the HyprCosmic fork's Patch A.
// Under a stock compositor this module stays empty and the
// rest of the bar still works.
// tray Needs a StatusNotifierWatcher. cosmic-panel normally
// supplies one via cosmic-applet-status-area, and the
// hyprcosmic profile disables the panel, so waybar hosts the
// watcher itself here.
//
// ICONS. Every glyph below is a \uXXXX escape, never a literal character, and
// this file is generated from config.jsonc.in for exactly that reason:
//
// - These are Private Use Area codepoints. They survive being copied, but
// anything that re-types rather than copies them silently flattens them to
// spaces or drops them next to a neighbour, and the damage is invisible in
// a diff and indistinguishable from a font problem at runtime.
// - An escape is plain ASCII, so it cannot be damaged that way, and it names
// the intended glyph instead of leaving a box you have to identify.
//
// Every codepoint was checked against the installed JetBrainsMono Nerd Font
// with `fc-list :charset=<cp>` before use. That check is worth doing rather
// than copying HyDE's values: HyDE's pulseaudio module uses U+FA80 for the
// muted state, an old Material Design Icons codepoint that Nerd Fonts v3
// moved. It is absent from the installed font and would render as tofu, so
// U+F075F is used instead.
//
// NOT INCLUDED, deliberately:
//
// backlight The only device is acpi_video0, a firmware stub reporting
// brightness 49 of max 49 -- permanently 100% -- whose sysfs
// node is not writable by this user, with brightnessctl absent.
// The module would render a readout that never changes and a
// scroll action that never works: visible, plausible, and dead.
// That is the same failure class as the `pavucontrol` click this
// file used to carry, and it is not worth re-introducing for a
// percentage that is always the same number.
{
"layer": "top",
"position": "top",
"height": 34,
"spacing": 4,
"modules-left": ["ext/workspaces", "wlr/taskbar"],
"modules-center": ["mpris", "clock", "privacy"],
"modules-right": [
"idle_inhibitor",
"custom/swaync",
"bluetooth",
"pulseaudio",
"network",
"temperature",
"cpu",
"memory",
"power-profiles-daemon",
"battery",
"tray",
"custom/power"
],
"ext/workspaces": {
"format": "{name}",
"on-click": "activate"
},
"wlr/taskbar": {
"format": "{icon}",
"icon-size": 18,
"tooltip-format": "{title}",
"on-click": "activate",
"on-click-middle": "close"
},
// Renders nothing at all when no player is running, which is what we want
// from a centre module: it takes no space until there is something to say.
// playerctl backs every action here and is installed.
"mpris": {
"format": "{player_icon} {dynamic}",
"format-paused": "{status_icon} <i>{dynamic}</i>",
"dynamic-order": ["title", "artist"],
"dynamic-separator": " ",
"max-length": 40,
"interval": 1,
"player-icons": { "default": "@@NOTE@@" },
"status-icons": {
"playing": "@@PLAY@@",
"paused": "@@PAUSE@@",
"stopped": "@@STOP@@"
},
"on-click": "playerctl play-pause",
"on-click-middle": "playerctl previous",
"on-click-right": "playerctl next",
"on-scroll-up": "playerctl position 5+",
"on-scroll-down": "playerctl position 5-",
"tooltip-format": "{title}\nby {artist}\n{position} / {length}\n\nplayer: {player}"
},
"clock": {
"format": "{:%a %d %b %H:%M}",
"tooltip-format": "<tt><small>{calendar}</small></tt>"
},
// Only appears while something is actually capturing. Nothing is ignored:
// an exception list is how a privacy indicator stops being one.
"privacy": {
"icon-spacing": 6,
"transition-duration": 200,
"modules": [
{ "type": "screenshare", "tooltip": true },
{ "type": "audio-in", "tooltip": true }
]
},
"idle_inhibitor": {
"format": "{icon}",
"format-icons": {
"activated": "@@CAFFEINE_ON@@",
"deactivated": "@@CAFFEINE_OFF@@"
},
"tooltip-format-activated": "Caffeine on -- idling and blanking suppressed",
"tooltip-format-deactivated": "Caffeine off -- normal power settings apply"
},
// swaync is this session's notification daemon: it owns
// org.freedesktop.Notifications and is D-Bus activated through
// swaync.service, so it needs no autostart entry and this module starts it
// on first contact if nothing else has.
//
// It is here because the hyprcosmic profile disables cosmic-panel and
// COSMIC's notification applet lives in that panel. Without this there is
// no way to reach notification history at all.
"custom/swaync": {
"format": "{icon}",
"format-icons": {
"none": "@@BELL@@",
"notification": "@@BELL_DOT@@",
"dnd-none": "@@BELL_OFF@@",
"dnd-notification": "@@BELL_OFF@@",
"inhibited-none": "@@BELL@@",
"inhibited-notification": "@@BELL_DOT@@",
"dnd-inhibited-none": "@@BELL_OFF@@",
"dnd-inhibited-notification": "@@BELL_OFF@@"
},
"return-type": "json",
"exec": "swaync-client -swb",
"on-click": "swaync-client -t -sw",
"on-click-right": "swaync-client -d -sw",
"tooltip": true,
"escape": true
},
// bluez is running with an unblocked hci0. The click target is COSMIC's own
// settings page rather than blueman or overskride, neither of which is
// installed here.
"bluetooth": {
"format": "@@BT_ON@@",
"format-disabled": "@@BT_DISABLED@@",
"format-off": "@@BT_OFF@@",
"format-connected": "@@BT_ON@@ {num_connections}",
"tooltip-format": "{controller_alias}\n{num_connections} connected",
"tooltip-format-connected": "{controller_alias}\n\n{device_enumerate}",
"tooltip-format-enumerate-connected": "{device_alias}",
"on-click": "cosmic-settings bluetooth"
},
// The click used to be `pavucontrol`, which is not installed here: a button
// that looked live and did nothing. cosmic-settings ships with the desktop,
// so it cannot go missing the same way. Scroll and middle-click need no
// helper -- waybar changes volume itself and wpctl comes with pipewire.
"pulseaudio": {
"format": "{icon} {volume}%",
"format-muted": "@@VOL_MUTE@@",
"format-bluetooth": "{icon} {volume}%",
"format-bluetooth-muted": "@@VOL_MUTE@@",
"format-icons": {
"headphone": "@@VOL_HEADPHONE@@",
"headset": "@@VOL_HEADSET@@",
"hands-free": "@@VOL_HANDSFREE@@",
"phone": "@@VOL_PHONE@@",
"car": "@@VOL_CAR@@",
"default": ["@@VOL_LO@@", "@@VOL_MID@@", "@@VOL_HI@@"]
},
"scroll-step": 5,
"tooltip-format": "{desc}\n{icon} {volume}%",
"on-click": "cosmic-settings sound",
"on-click-middle": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
},
"network": {
"format-wifi": "@@WIFI@@ {signalStrength}%",
"format-ethernet": "@@ETH@@ wired",
"format-linked": "@@ETH@@ {ifname}",
"format-disconnected": "@@NET_OFF@@ offline",
"tooltip-format": "{ifname}: {ipaddr}",
"tooltip-format-wifi": "{essid} ({signalStrength}%)\n{ifname}: {ipaddr}",
"on-click": "cosmic-settings network"
},
// Tctl from k10temp, not thermal_zone0. The two read within a degree of
// each other here, but the hwmon path names the CPU sensor explicitly.
//
// It is given as the PCI device's hwmon directory rather than
// /sys/class/hwmon/hwmon6 because hwmonN numbering is assigned in probe
// order and is not stable across boots. The PCI address is.
"temperature": {
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:18.3/hwmon",
"input-filename": "temp1_input",
"format": "@@TEMP@@ {temperatureC} C",
"critical-threshold": 90,
"interval": 5,
"tooltip-format": "CPU package: {temperatureC} C"
},
"cpu": {
"format": "@@CPU@@ {usage}%",
"interval": 5
},
"memory": {
"format": "@@MEM@@ {percentage}%",
"interval": 5,
"tooltip-format": "{used:0.1f}G of {total:0.1f}G"
},
// Backed by tuned-ppd, which owns net.hadess.PowerProfiles on this system.
"power-profiles-daemon": {
"format": "{icon}",
"format-icons": {
"default": "@@PROFILE@@",
"performance": "@@PERF@@",
"balanced": "@@BALANCED@@",
"power-saver": "@@SAVER@@"
},
"tooltip-format": "power profile: {profile}",
"tooltip": true
},
"battery": {
"states": { "warning": 30, "critical": 15 },
"format": "{icon} {capacity}%",
"format-charging": "@@PLUG@@ {capacity}%",
"format-plugged": "@@PLUG@@ {capacity}%",
"format-icons": [
"@@BAT_00@@",
"@@BAT_10@@",
"@@BAT_20@@",
"@@BAT_30@@",
"@@BAT_40@@",
"@@BAT_50@@",
"@@BAT_60@@",
"@@BAT_70@@",
"@@BAT_80@@",
"@@BAT_90@@",
"@@BAT_100@@"
],
"tooltip-format": "{timeTo}\n{power:0.1f}W",
"on-click": "cosmic-settings power"
},
"tray": { "icon-size": 18, "spacing": 8 },
// Last on the bar, because it is the one button here that can end the
// session or the uptime and the far corner is the hardest place to hit by
// accident.
//
// It is here for the same reason "custom/swaync" is: the hyprcosmic profile
// disables cosmic-panel, and COSMIC's power applet lives in that panel.
// Without this there is no logout, reboot or shutdown anywhere in the
// session -- the only way out was `systemctl reboot` from a terminal.
//
// The click runs the same script as the $mainMod SHIFT E binding rather
// than calling systemctl here, so the confirmation step cannot be bypassed
// by using the mouse.
"custom/power": {
"format": "@@POWER@@",
"tooltip": true,
"tooltip-format": "Lock, suspend, log out, reboot or shut down",
"on-click": "hyprcosmic-powermenu"
}
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Generate waybar config.jsonc with \\uXXXX escapes from verified codepoints.
Every codepoint here was confirmed present in JetBrainsMono Nerd Font with
`fc-list :charset=<cp>`. The template is pure ASCII and uses @TOKEN@
placeholders so that no PUA character is ever typed by hand.
"""
import json
import re
import sys
# name -> codepoint. Verified present in the installed Nerd Font.
ICONS = {
"VOL_LO": 0xF026,
"VOL_MID": 0xF027,
"VOL_HI": 0xF028,
"VOL_HEADPHONE": 0xF025,
"VOL_HEADSET": 0xF025,
"VOL_HANDSFREE": 0xF025,
"VOL_PHONE": 0xF095,
"VOL_CAR": 0xF1B9,
"VOL_MUTE": 0xF075F,
"BT_ON": 0xF293,
"BT_DISABLED": 0xF294,
"BT_OFF": 0xF00B2,
"CPU": 0xF035B,
"MEM": 0xF0EE0,
"TEMP": 0xF2CA,
"WIFI": 0xF1EB,
"ETH": 0xF0200,
"NET_OFF": 0xF092F,
"PLUG": 0xF1E6,
"CAFFEINE_ON": 0xF0176,
"CAFFEINE_OFF": 0xF06CA,
"PLAY": 0xF04B,
"PAUSE": 0xF04C,
"STOP": 0xF04D,
"NOTE": 0xF001,
"BELL": 0xF0F3,
"BELL_DOT": 0xF009A,
"BELL_OFF": 0xF1F6,
"PERF": 0xF135,
"SAVER": 0xF06C,
"BALANCED": 0xF24E,
"PROFILE": 0xF0E7,
"POWER": 0xF011,
"BAT_00": 0xF008E,
"BAT_10": 0xF007A,
"BAT_20": 0xF007B,
"BAT_30": 0xF007C,
"BAT_40": 0xF007D,
"BAT_50": 0xF007E,
"BAT_60": 0xF007F,
"BAT_70": 0xF0080,
"BAT_80": 0xF0081,
"BAT_90": 0xF0082,
"BAT_100": 0xF0079,
}
def escape(cp: int) -> str:
"""JSON escape for one codepoint, surrogate pair when above the BMP."""
return json.dumps(chr(cp), ensure_ascii=True)[1:-1]
def main() -> int:
template_path, out_path = sys.argv[1], sys.argv[2]
text = open(template_path, encoding="ascii").read()
unknown = {t for t in re.findall(r"@@([A-Z0-9_]+)@@", text) if t not in ICONS}
if unknown:
print("unknown tokens:", sorted(unknown), file=sys.stderr)
return 1
used = set()
def sub(m):
used.add(m.group(1))
return escape(ICONS[m.group(1)])
out = re.sub(r"@@([A-Z0-9_]+)@@", sub, text)
if not out.isascii():
print("generated file is not pure ASCII", file=sys.stderr)
return 1
open(out_path, "w", encoding="ascii").write(out)
unused = sorted(set(ICONS) - used)
print(f"wrote {out_path}: {len(out.splitlines())} lines, {len(used)} icons used")
if unused:
print("unused icon names:", unused)
return 0
if __name__ == "__main__":
sys.exit(main())
+33
View File
@@ -0,0 +1,33 @@
/* Default colours for the HyprCosmic bar.
*
* Two name sets are defined here, and both matter:
*
* - `main-bg`, `wb-act-bg` and friends are HyDE's names. A HyDE theme's
* waybar.theme is nothing but a list of these, so defining them here means
* a theme file can override them by being imported after this one.
* - `bar-bg`, `accent` and friends are the names rules.css actually uses.
*
* bridge-hyde.css maps the first set onto the second. Defining HyDE's names
* here as well is what makes a missing theme file harmless: the bridge always
* has something to resolve against, so an unthemed bar renders with these
* values instead of failing to parse.
*
* Values are Tokyo Night, matching cosmic-conf's own defaults.
*/
/* HyDE's names. Overridden by ~/.config/waybar/theme.css when a theme is in. */
@define-color main-bg #1a1b26;
@define-color main-fg #c0caf5;
@define-color wb-act-bg #7aa2f7;
@define-color wb-act-fg #1a1b26;
@define-color wb-hvr-bg #7aa2f7;
@define-color wb-hvr-fg #565f89;
/* Names with no HyDE equivalent, so never themed and always these values. */
@define-color warning #e0af68;
@define-color critical #f7768e;
/* The bar's own background. HyDE themes define this one under the same name
* we use, and typically as a low-alpha rgba() so the compositor's blur shows
* through. */
@define-color bar-bg rgba(26, 27, 38, 0.85);
+155
View File
@@ -0,0 +1,155 @@
/* Geometry and layout for the HyprCosmic bar.
*
* No colour literals: every colour here is a name defined by palette.css and
* possibly re-pointed by bridge-hyde.css. That separation is what lets a theme
* change the palette without touching a single rule.
*
* The look is HyDE's: a strip carrying rounded pills rather than a flat row of
* text. HyDE gets its pills to float by making the bar itself transparent and
* letting the compositor blur the desktop behind them; cosmic-comp has no
* rule-driven blur, so the bar keeps a background here and the pills lift off
* it instead. See bridge-hyde.css for the two colours that implements.
*/
* {
/* JetBrainsMono Nerd Font first, and it has to be first.
*
* This previously named "FontAwesome 6 Free", which is not even the
* installed family's real name ("Font Awesome 6 Free") -- fontconfig's
* fuzzy matching resolved it anyway, to the Regular face, which carries
* only a small subset of the icon set. Every glyph outside that subset
* then came from whatever fallback fontconfig picked per character, so the
* bar's icons were only accidentally consistent.
*
* The Nerd Font carries the whole icon set and the text, so naming it
* first makes one font answer for both. Every codepoint in config.jsonc
* was checked against this family specifically. */
font-family: "JetBrainsMono Nerd Font", "Noto Sans", sans-serif;
font-size: 13px;
border: none;
border-radius: 0;
min-height: 0;
}
window#waybar {
background: @bar-bg;
color: @bar-fg;
}
/* Workspaces: pills, with the active one filled rather than underlined. */
#workspaces {
margin: 0 4px;
}
#workspaces button {
padding: 0 10px;
margin: 4px 2px;
border-radius: 10px;
color: @muted;
background: transparent;
}
#workspaces button.active {
color: @wb-act-fg;
background: @accent;
}
#workspaces button:hover {
color: @wb-hvr-fg;
background: @wb-hvr-bg;
}
#taskbar button {
padding: 0 6px;
margin: 4px 1px;
border-radius: 10px;
background: transparent;
}
#taskbar button.active {
background: @module-bg;
}
#taskbar button:hover {
background: @wb-hvr-bg;
}
/* Every status module gets the same pill. Listed one per line because waybar
* takes the CSS id from the module name with `/` turned into `-`, so these are
* not guessable from the config and are worth being able to read down. */
#mpris,
#clock,
#privacy,
#idle_inhibitor,
#custom-swaync,
#bluetooth,
#pulseaudio,
#network,
#temperature,
#cpu,
#memory,
#power-profiles-daemon,
#battery,
#tray,
#custom-power {
padding: 0 10px;
margin: 4px 2px;
border-radius: 10px;
background: @module-bg;
}
/* The only module here that is a button rather than a readout, so it is the
* only one that gains anything from a hover state. Colour rather than
* background, to match how the status colours below signal without moving
* anything. */
#custom-power:hover {
color: @critical;
}
#clock {
font-weight: bold;
}
/* mpris renders nothing when no player is running. Without this it would still
* draw an empty pill, so the padding goes away with the content. */
#mpris {
padding: 0;
background: transparent;
}
#mpris.playing,
#mpris.paused {
padding: 0 10px;
background: @module-bg;
}
/* Same reasoning: privacy is only present while something is capturing. */
#privacy {
padding: 0;
background: transparent;
}
#privacy-item {
padding: 0 8px;
margin: 4px 2px;
border-radius: 10px;
background: @module-bg;
color: @warning;
}
/* States. These recolour the text inside the pill rather than the pill, so a
* warning cannot make a module unreadable against its own background. */
#battery.warning { color: @warning; }
#battery.critical { color: @critical; }
#temperature.critical { color: @critical; }
#network.disconnected { color: @critical; }
#pulseaudio.muted { color: @muted; }
#idle_inhibitor.activated { color: @accent; }
#custom-swaync.notification { color: @accent; }
#custom-swaync.dnd-none,
#custom-swaync.dnd-notification { color: @muted; }
/* No `#tray:empty` rule to hide the pill when nothing is in the tray: GTK's
* CSS has no :empty pseudo-class and rejects the whole stylesheet for it,
* which is fatal rather than cosmetic. waybar exits and the bar never appears.
*/
+32
View File
@@ -0,0 +1,32 @@
/* Waybar styling for a HyprCosmic session.
*
* This file is only an import list, because the order is the whole design:
*
* 1. palette.css default colours, under both our names and HyDE's
* 2. theme.css the installed HyDE theme's waybar.theme, if any
* 3. bridge-hyde.css maps HyDE's colour names onto the ones the rules use
* 4. rules.css geometry and layout; no colour literals at all
*
* Every entry is an @import, so they are read strictly in sequence and a later
* definition of a colour wins over an earlier one. That is what lets step 2
* recolour the bar without step 4 knowing a theme exists.
*
* Step 2 is a copy of the installed theme's waybar.theme, kept as a sibling of
* this file rather than read from HyDE's own ~/.config/waybar/theme.css.
*
* That copy exists because a missing @import is fatal in GTK, not a warning:
* pointing at HyDE's path directly means the whole stylesheet fails to load on
* any machine where no theme has been imported. A sibling file we create at
* install time is always present -- empty when there is no theme, in which
* case the defaults from step 1 stand. palette.css defines HyDE's names too,
* so the bridge in step 3 resolves either way.
*
* Consequently this file belongs at ~/.config/hyprcosmic/waybar/style.css, not
* under /usr/share: a relative @import resolves against the importing file,
* and theme.css is per-user.
*/
@import url("file:///usr/share/hyprcosmic/waybar/palette.css");
@import url("theme.css");
@import url("file:///usr/share/hyprcosmic/waybar/bridge-hyde.css");
@import url("file:///usr/share/hyprcosmic/waybar/rules.css");
+4
View File
@@ -0,0 +1,4 @@
# Single-core builds: this crate is developed on a machine where parallel
# rustc jobs are not wanted.
[build]
jobs = 1
+481
View File
@@ -0,0 +1,481 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cosmic-conf"
version = "0.1.0"
dependencies = [
"flate2",
"notify",
"ron",
"serde",
"tar",
"tempfile",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "inotify"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
dependencies = [
"bitflags",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]]
name = "kqueue"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ron"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
dependencies = [
"bitflags",
"once_cell",
"serde",
"serde_derive",
"typeid",
"unicode-ident",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "cosmic-conf"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-only"
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
[dependencies]
flate2 = "1.1.9"
notify = "8.2.0"
ron = "0.12"
serde = { version = "1.0.229", features = ["derive"] }
tar = "0.4.46"
[features]
# `emit` links cosmic-config and the component crates. Off by default so the
# pure units (parser, schema, resolve) build and test without the libcosmic
# dependency graph.
default = []
emit = []
[dev-dependencies]
tempfile = "3.27.0"
File diff suppressed because it is too large Load Diff
+460
View File
@@ -0,0 +1,460 @@
//! Hyprland `bind` lines -> COSMIC shortcut bindings.
//!
//! `bind = SUPER, D, exec, rofi -show drun` is the single most recognisable
//! line in a hyprland.conf, so it is the one piece of the idiom that has to
//! feel native rather than translated.
//!
//! The target is the `custom` key of `com.system76.CosmicSettings.Shortcuts`,
//! which the compositor merges over `defaults`, letting a bind here override a
//! stock COSMIC shortcut without touching the system file
//! (cosmic-settings-daemon `config/src/shortcuts/mod.rs`: `shortcuts()` reads
//! `defaults`, then extends with `custom`).
//!
//! Actions are rendered as RON text rather than modelled as an enum. COSMIC's
//! `Action` has forty-odd variants and this crate deliberately does not link
//! the cosmic crates; mirroring the enum would mean re-copying it every time
//! upstream adds a variant, and the mapping table below only ever needs a few.
use std::fmt::Write as _;
use crate::parser::Span;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bind {
/// COSMIC modifier names, deduplicated and in COSMIC's own order.
pub mods: Vec<&'static str>,
/// xkb keysym name. `None` is a modifier-only binding, which COSMIC
/// supports and its defaults use for the launcher on bare Super.
pub key: Option<String>,
/// Pre-rendered RON, e.g. `Spawn("rofi -show drun")` or `Focus(Left)`.
pub action: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BindError {
pub message: String,
pub help: Option<String>,
pub span: Span,
}
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> BindError {
BindError {
message: message.into(),
help: help.map(str::to_string),
span,
}
}
/// Modifier spellings Hyprland accepts, longest first so that the greedy scan
/// below consumes `SUPERSHIFT` correctly rather than stopping at a prefix.
const MODIFIERS: &[(&str, &str)] = &[
("SUPERKEY", "Super"),
("CONTROL", "Ctrl"),
("SHIFT", "Shift"),
("SUPER", "Super"),
("LOGO", "Super"),
("MOD4", "Super"),
("MOD1", "Alt"),
("CTRL", "Ctrl"),
("META", "Super"),
("ALT", "Alt"),
("WIN", "Super"),
];
/// COSMIC writes modifiers in this order in its own defaults; matching it keeps
/// generated files diffable against hand-written ones.
const MODIFIER_ORDER: &[&str] = &["Super", "Ctrl", "Alt", "Shift"];
/// Hyprland allows `SUPER SHIFT`, `SUPER+SHIFT` and bare `SUPERSHIFT`, so
/// separators are stripped and the remainder is consumed greedily.
fn parse_modifiers(raw: &str, span: Span) -> Result<Vec<&'static str>, BindError> {
let mut rest: String = raw
.chars()
.filter(|c| !c.is_whitespace() && *c != '+' && *c != '_')
.collect::<String>()
.to_ascii_uppercase();
let mut found: Vec<&'static str> = Vec::new();
'outer: while !rest.is_empty() {
for (spelling, cosmic) in MODIFIERS {
if let Some(tail) = rest.strip_prefix(spelling) {
if !found.contains(cosmic) {
found.push(cosmic);
}
rest = tail.to_string();
continue 'outer;
}
}
return Err(err(
span,
format!("unknown modifier `{rest}`"),
Some("known modifiers: SUPER, CTRL, ALT, SHIFT"),
));
}
found.sort_by_key(|m| {
MODIFIER_ORDER
.iter()
.position(|o| o == m)
.unwrap_or(usize::MAX)
});
Ok(found)
}
/// Named keys whose xkb spelling differs from what a Hyprland user types.
///
/// Anything absent falls through unchanged, so exact keysyms such as
/// `XF86AudioRaiseVolume` keep working without needing an entry here.
const KEY_NAMES: &[(&str, &str)] = &[
("return", "Return"),
("enter", "Return"),
("escape", "Escape"),
("esc", "Escape"),
("tab", "Tab"),
("backspace", "BackSpace"),
("delete", "Delete"),
("insert", "Insert"),
("home", "Home"),
("end", "End"),
("pageup", "Prior"),
("pagedown", "Next"),
("left", "Left"),
("right", "Right"),
("up", "Up"),
("down", "Down"),
("print", "Print"),
];
/// COSMIC's defaults spell letters lowercase (`key: "q"`) and punctuation by
/// keysym name (`key: "slash"`), so normalise toward that.
fn normalize_key(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let lower = trimmed.to_ascii_lowercase();
if let Some((_, name)) = KEY_NAMES.iter().find(|(k, _)| *k == lower) {
return Some((*name).to_string());
}
// Function keys are uppercase-F in xkb.
if let Some(n) = lower.strip_prefix('f') {
if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) {
return Some(format!("F{n}"));
}
}
if trimmed.len() == 1 && trimmed.chars().all(|c| c.is_ascii_alphabetic()) {
return Some(lower);
}
Some(trimmed.to_string())
}
fn ron_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
_ => out.push(c),
}
}
out.push('"');
out
}
fn direction(arg: &str, span: Span, dispatcher: &str) -> Result<&'static str, BindError> {
Ok(match arg.trim().to_ascii_lowercase().as_str() {
"l" | "left" => "Left",
"r" | "right" => "Right",
"u" | "up" => "Up",
"d" | "down" => "Down",
other => {
return Err(err(
span,
format!("`{dispatcher}` needs a direction, got `{other}`"),
Some("use l, r, u or d"),
))
}
})
}
fn workspace_index(arg: &str, span: Span, dispatcher: &str) -> Result<u8, BindError> {
arg.trim().parse::<u8>().map_err(|_| {
err(
span,
format!(
"`{dispatcher}` needs a workspace number, got `{}`",
arg.trim()
),
Some("COSMIC addresses workspaces 1-255 by index"),
)
})
}
/// Translate a Hyprland dispatcher and its argument into RON for COSMIC's
/// `Action`.
///
/// Only dispatchers with a genuine COSMIC equivalent are mapped. A dispatcher
/// that merely looks similar is rejected instead of approximated, because a
/// keybinding that silently does the wrong thing is worse than one that fails
/// to compile.
fn action(dispatcher: &str, arg: &str, span: Span) -> Result<String, BindError> {
let d = dispatcher.trim().to_ascii_lowercase();
Ok(match d.as_str() {
"exec" => {
let cmd = arg.trim();
if cmd.is_empty() {
return Err(err(span, "`exec` needs a command", None));
}
// cosmic-comp runs this through `/bin/sh -c`
// (`src/input/actions.rs`: `spawn_command`), so a full command line
// with arguments and quoting behaves as written.
format!("Spawn({})", ron_string(cmd))
}
"killactive" => "Close".into(),
"fullscreen" => "Fullscreen".into(),
"togglefloating" => "ToggleWindowFloating".into(),
"togglesplit" => "ToggleOrientation".into(),
"togglegroup" => "ToggleStacking".into(),
"pin" => "ToggleSticky".into(),
"exit" => "System(LogOut)".into(),
"movefocus" => format!("Focus({})", direction(arg, span, &d)?),
"movewindow" => format!("Move({})", direction(arg, span, &d)?),
"workspace" => format!("Workspace({})", workspace_index(arg, span, &d)?),
"movetoworkspace" => format!("MoveToWorkspace({})", workspace_index(arg, span, &d)?),
"movetoworkspacesilent" => format!("SendToWorkspace({})", workspace_index(arg, span, &d)?),
"focusmonitor" => format!("SwitchOutput({})", direction(arg, span, &d)?),
"movewindowtomonitor" => format!("MoveToOutput({})", direction(arg, span, &d)?),
// Present in Hyprland, absent from COSMIC. Named explicitly so the
// error says why rather than "unknown".
"pseudo" | "forcerendererreload" | "submap" | "toggleopaque" | "centerwindow"
| "splitratio" | "cyclenext" | "swapnext" => {
return Err(err(
span,
format!("`{d}` has no COSMIC equivalent"),
Some("remove the bind, or use `exec` to run a program instead"),
))
}
other => {
return Err(err(
span,
format!("unknown dispatcher `{other}`"),
Some("supported: exec, killactive, fullscreen, togglefloating, togglesplit, movefocus, movewindow, workspace, movetoworkspace, exit"),
))
}
})
}
/// Parse the value of one `bind = ...` line.
///
/// Shape is `MODS, KEY, dispatcher, args`, with args keeping any further
/// commas, since `exec` commands routinely contain them.
pub fn parse_bind(value: &str, span: Span) -> Result<Bind, BindError> {
let parts: Vec<&str> = value.splitn(4, ',').collect();
if parts.len() < 3 {
return Err(err(
span,
"a bind needs at least MODS, KEY and a dispatcher",
Some("for example: bind = SUPER, D, exec, rofi -show drun"),
));
}
let mods = parse_modifiers(parts[0], span)?;
let key = normalize_key(parts[1]);
if mods.is_empty() && key.is_none() {
return Err(err(span, "a bind needs a modifier or a key", None));
}
let arg = parts.get(3).copied().unwrap_or("");
let action = action(parts[2], arg, span)?;
Ok(Bind { mods, key, action })
}
/// Render the collected binds as the RON map COSMIC stores in `custom`.
pub fn render(binds: &[Bind]) -> String {
let mut out = String::from("{\n");
for b in binds {
let mods = b
.mods
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(", ");
match &b.key {
// `key` is `skip_serializing_if = "Option::is_none"` on COSMIC's
// `Binding`, and its own defaults omit it for `(modifiers: [Super])`.
Some(k) => {
let _ = writeln!(
out,
" (modifiers: [{mods}], key: {}): {},",
ron_string(k),
b.action
);
}
None => {
let _ = writeln!(out, " (modifiers: [{mods}]): {},", b.action);
}
}
}
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
const S: Span = Span {
line: 1,
col: 1,
len: 1,
};
fn bind(v: &str) -> Bind {
parse_bind(v, S).expect("should parse")
}
#[test]
fn the_canonical_hyprland_launcher_bind() {
let b = bind("SUPER, D, exec, rofi -show drun");
assert_eq!(b.mods, vec!["Super"]);
assert_eq!(b.key.as_deref(), Some("d"));
assert_eq!(b.action, r#"Spawn("rofi -show drun")"#);
}
#[test]
fn modifiers_accept_every_separator_hyprland_does() {
for spelling in ["SUPER SHIFT", "SUPER+SHIFT", "SUPERSHIFT", "super shift"] {
assert_eq!(
bind(&format!("{spelling}, Q, killactive")).mods,
vec!["Super", "Shift"],
"failed for `{spelling}`"
);
}
}
#[test]
fn modifiers_are_ordered_like_cosmics_own_defaults() {
assert_eq!(
bind("SHIFT ALT CTRL SUPER, Q, killactive").mods,
vec!["Super", "Ctrl", "Alt", "Shift"]
);
}
#[test]
fn a_bind_with_no_key_is_modifier_only() {
// COSMIC's defaults bind bare Super to the launcher this way.
let b = bind("SUPER, , exec, rofi -show drun");
assert_eq!(b.key, None);
assert_eq!(
render(&[b]),
"{\n (modifiers: [Super]): Spawn(\"rofi -show drun\"),\n}\n"
);
}
#[test]
fn keys_normalise_to_xkb_spelling() {
assert_eq!(bind("SUPER, Q, killactive").key.as_deref(), Some("q"));
assert_eq!(
bind("SUPER, Return, killactive").key.as_deref(),
Some("Return")
);
assert_eq!(
bind("SUPER, enter, killactive").key.as_deref(),
Some("Return")
);
assert_eq!(bind("SUPER, f5, killactive").key.as_deref(), Some("F5"));
assert_eq!(
bind("SUPER, slash, killactive").key.as_deref(),
Some("slash")
);
// Unknown names pass through so exact keysyms stay usable.
assert_eq!(
bind("SUPER, XF86AudioRaiseVolume, killactive")
.key
.as_deref(),
Some("XF86AudioRaiseVolume")
);
}
#[test]
fn exec_keeps_commas_in_the_command() {
assert_eq!(
bind("SUPER, E, exec, sh -c 'echo a, b'").action,
r#"Spawn("sh -c 'echo a, b'")"#
);
}
#[test]
fn quotes_in_a_command_are_escaped_not_emitted_raw() {
// Otherwise the generated RON would not parse.
assert_eq!(
bind(r#"SUPER, E, exec, echo "hi""#).action,
r#"Spawn("echo \"hi\"")"#
);
}
#[test]
fn dispatchers_map_to_cosmic_actions() {
assert_eq!(bind("SUPER, Q, killactive").action, "Close");
assert_eq!(bind("SUPER, F, fullscreen").action, "Fullscreen");
assert_eq!(bind("SUPER, left, movefocus, l").action, "Focus(Left)");
assert_eq!(
bind("SUPER SHIFT, left, movewindow, l").action,
"Move(Left)"
);
assert_eq!(bind("SUPER, 1, workspace, 1").action, "Workspace(1)");
assert_eq!(
bind("SUPER SHIFT, 1, movetoworkspace, 1").action,
"MoveToWorkspace(1)"
);
}
#[test]
fn a_dispatcher_without_an_equivalent_is_refused_not_approximated() {
let e = parse_bind("SUPER, P, pseudo", S).unwrap_err();
assert!(e.message.contains("no COSMIC equivalent"), "{}", e.message);
assert!(e.help.is_some());
}
#[test]
fn unknown_dispatchers_and_modifiers_are_reported() {
assert!(parse_bind("SUPER, X, frobnicate", S)
.unwrap_err()
.message
.contains("unknown dispatcher"));
assert!(parse_bind("HYPER, X, killactive", S)
.unwrap_err()
.message
.contains("unknown modifier"));
}
#[test]
fn a_truncated_bind_says_what_shape_is_expected() {
let e = parse_bind("SUPER, D", S).unwrap_err();
assert!(e.help.unwrap().contains("rofi -show drun"));
}
#[test]
fn rendering_matches_the_shape_cosmic_writes_in_its_defaults() {
let out = render(&[
bind("SUPER, , exec, rofi -show drun"),
bind("SUPER, slash, exec, rofi -show drun"),
bind("SUPER SHIFT, Q, killactive"),
]);
assert_eq!(
out,
concat!(
"{\n",
" (modifiers: [Super]): Spawn(\"rofi -show drun\"),\n",
" (modifiers: [Super], key: \"slash\"): Spawn(\"rofi -show drun\"),\n",
" (modifiers: [Super, Shift], key: \"q\"): Close,\n",
"}\n"
)
);
}
}
+670
View File
@@ -0,0 +1,670 @@
//! Resolved writes -> the cosmic-config tree.
//!
//! Spike 2 established the mechanism (see the spec's verified-findings table):
//! cosmic-config is a filesystem key-value store at
//! `$XDG_CONFIG_HOME/cosmic/<component>/v<n>/<key>`, each file holding one RON
//! literal. `Config::watch` (`cosmic-config/src/lib.rs:377`) is a `notify`
//! inotify watch on that directory which derives changed keys from file paths,
//! so a plain atomic write is observed exactly like a write from the typed API.
//! That is why this module needs `ron` rather than the whole libcosmic graph.
//!
//! Emission is two-stage on purpose. `plan` reads current state and renders
//! every file's new contents without touching disk; `apply` then writes. A
//! failure while planning therefore leaves the desktop untouched, preserving
//! the transactional guarantee `resolve` starts.
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::resolve::{Resolved, TargetKey, Value, Write, WriteKind};
/// Mirror of `cosmic_theme::CornerRadii` (`cosmic-theme/src/model/corner.rs:5`).
///
/// Duplicated rather than depended upon so this crate stays free of the
/// libcosmic build graph. The field set and defaults are pinned by tests; if
/// upstream adds a radius, round-tripping would silently drop it, so
/// `deny_unknown_fields` turns that into a loud parse error instead.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct CornerRadii {
radius_0: [f32; 4],
radius_xs: [f32; 4],
radius_s: [f32; 4],
radius_m: [f32; 4],
radius_l: [f32; 4],
radius_xl: [f32; 4],
}
impl Default for CornerRadii {
/// `corner.rs:20-31`.
fn default() -> Self {
Self {
radius_0: [0.0; 4],
radius_xs: [4.0; 4],
radius_s: [8.0; 4],
radius_m: [16.0; 4],
radius_l: [32.0; 4],
radius_xl: [160.0; 4],
}
}
}
impl CornerRadii {
fn field_mut(&mut self, name: &str) -> Option<&mut [f32; 4]> {
Some(match name {
"radius_0" => &mut self.radius_0,
"radius_xs" => &mut self.radius_xs,
"radius_s" => &mut self.radius_s,
"radius_m" => &mut self.radius_m,
"radius_l" => &mut self.radius_l,
"radius_xl" => &mut self.radius_xl,
_ => return None,
})
}
}
#[derive(Debug)]
pub enum EmitError {
Io(io::Error),
/// A projected target whose composite shape this emitter cannot rebuild.
UnsupportedComposite {
key: String,
detail: String,
},
/// An existing file could not be parsed, so read-modify-write is unsafe.
Unreadable {
path: PathBuf,
detail: String,
},
NoConfigDirectory,
}
impl fmt::Display for EmitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EmitError::Io(e) => write!(f, "io error: {e}"),
EmitError::UnsupportedComposite { key, detail } => {
write!(f, "cannot write `{key}`: {detail}")
}
EmitError::Unreadable { path, detail } => {
write!(f, "cannot parse existing `{}`: {detail}", path.display())
}
EmitError::NoConfigDirectory => write!(f, "no config directory available"),
}
}
}
impl std::error::Error for EmitError {}
impl From<io::Error> for EmitError {
fn from(e: io::Error) -> Self {
EmitError::Io(e)
}
}
/// One file's worth of pending change. `previous` powers `apply --diff`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Planned {
pub path: PathBuf,
pub contents: String,
pub previous: Option<String>,
}
impl Planned {
/// A write that would not change anything on disk.
pub fn is_noop(&self) -> bool {
self.previous.as_deref() == Some(self.contents.as_str())
}
}
pub struct Emitter {
root: PathBuf,
}
impl Emitter {
/// Locate the cosmic-config root the same way cosmic-config does:
/// `$XDG_CONFIG_HOME/cosmic`, falling back to `$HOME/.config/cosmic`.
pub fn from_env() -> Result<Self, EmitError> {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(x) if !x.is_empty() => PathBuf::from(x),
_ => {
let home = std::env::var_os("HOME").ok_or(EmitError::NoConfigDirectory)?;
PathBuf::from(home).join(".config")
}
};
Ok(Self {
root: base.join("cosmic"),
})
}
pub fn with_root(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
fn path_for(&self, target: &TargetKey) -> PathBuf {
self.root
.join(&target.component)
.join(format!("v{}", target.version))
.join(&target.key)
}
/// Render every write without touching disk.
///
/// Returns all errors rather than the first, matching `resolve`'s behaviour
/// so a user sees the whole picture in one pass.
pub fn plan(&self, resolved: &Resolved) -> Result<Vec<Planned>, Vec<EmitError>> {
let mut planned = Vec::new();
let mut errors = Vec::new();
for write in &resolved.writes {
match self.plan_one(write) {
Ok(p) => planned.push(p),
Err(e) => errors.push(e),
}
}
if errors.is_empty() {
Ok(planned)
} else {
Err(errors)
}
}
fn plan_one(&self, write: &Write) -> Result<Planned, EmitError> {
let path = self.path_for(&write.target);
let previous = match fs::read_to_string(&path) {
Ok(s) => Some(s),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e.into()),
};
let contents = match &write.kind {
WriteKind::Whole(v) => render(v),
WriteKind::Projected(fields) => {
composite(&write.target, fields, previous.as_deref(), &path)?
}
// No merge with `previous`: cosmic.conf owns this value outright,
// which is the whole point of the one-way model. Anything set in
// COSMIC's own settings UI is replaced, not accumulated.
WriteKind::Verbatim(s) => s.clone(),
};
Ok(Planned {
path,
contents,
previous,
})
}
/// Write the plan. Callers should `plan` first so that failures surface
/// before any file is touched.
pub fn apply(&self, planned: &[Planned]) -> Result<usize, EmitError> {
let mut written = 0;
for p in planned {
if p.is_noop() {
continue;
}
if let Some(dir) = p.path.parent() {
fs::create_dir_all(dir)?;
}
atomic_write(&p.path, &p.contents)?;
written += 1;
}
Ok(written)
}
}
/// Write via temp-file + rename so a reader never observes a partial file.
///
/// The temp name carries cosmic-config's `.atomicwrite` prefix
/// (`cosmic-config/src/lib.rs:408`) so its watcher ignores the intermediate
/// file and reacts only to the final rename.
fn atomic_write(path: &Path, contents: &str) -> io::Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let tmp = dir.join(format!(".atomicwrite.{name}"));
fs::write(&tmp, contents)?;
fs::rename(&tmp, path)?;
Ok(())
}
/// Render a scalar as the RON literal cosmic-config expects.
///
/// Exact formatting is not load-bearing — cosmic-config reads with
/// `ron::from_str` (`lib.rs:468`) — but the *shape* is: `Option<Srgb>` has three
/// components, `Option<Srgba>` four.
fn render(v: &Value) -> String {
match v {
Value::Bool(b) => b.to_string(),
Value::U32(n) => n.to_string(),
Value::F32(n) => render_f32(*n),
Value::Str(s) => format!("{s:?}"),
Value::Rgb(r, g, b) => format!(
"Some((red: {}, green: {}, blue: {}))",
render_f32(byte_to_f32(*r)),
render_f32(byte_to_f32(*g)),
render_f32(byte_to_f32(*b)),
),
Value::Rgba(r, g, b, a) => format!(
"Some((red: {}, green: {}, blue: {}, alpha: {}))",
render_f32(byte_to_f32(*r)),
render_f32(byte_to_f32(*g)),
render_f32(byte_to_f32(*b)),
render_f32(byte_to_f32(*a)),
),
}
}
fn byte_to_f32(b: u8) -> f32 {
b as f32 / 255.0
}
/// RON needs floats to look like floats: a bare `10` would deserialize as an
/// integer and fail a `f32` field.
fn render_f32(n: f32) -> String {
if n.fract() == 0.0 {
format!("{n:.1}")
} else {
format!("{n}")
}
}
/// Rebuild a composite value from folded projections plus whatever is already
/// on disk.
///
/// Only shapes that can be reconstructed correctly are supported. Anything else
/// is a hard error rather than a partial write, because silently writing an
/// incomplete composite would drop the user's other fields.
fn composite(
target: &TargetKey,
fields: &BTreeMap<Vec<String>, Value>,
previous: Option<&str>,
path: &Path,
) -> Result<String, EmitError> {
match target.key.as_str() {
// ThemeBuilder.gaps: (u32, u32) ordered (outer, inner) — theme.rs:895,
// default (0, 8) — theme.rs:939.
"gaps" => {
let (mut outer, mut inner) = match previous {
Some(text) => {
ron::from_str::<(u32, u32)>(text).map_err(|e| EmitError::Unreadable {
path: path.to_path_buf(),
detail: e.to_string(),
})?
}
None => (0, 8),
};
for (p, v) in fields {
let Value::U32(n) = v else {
return Err(EmitError::UnsupportedComposite {
key: target.key.clone(),
detail: format!("expected an integer for index {p:?}"),
});
};
match p.first().map(String::as_str) {
Some("0") => outer = *n,
Some("1") => inner = *n,
other => {
return Err(EmitError::UnsupportedComposite {
key: target.key.clone(),
detail: format!("unknown tuple index {other:?}"),
})
}
}
}
Ok(format!("({outer}, {inner})"))
}
// ThemeBuilder.corner_radii: six [f32; 4] fields — corner.rs:5.
"corner_radii" => {
let mut radii = match previous {
Some(text) => {
ron::from_str::<CornerRadii>(text).map_err(|e| EmitError::Unreadable {
path: path.to_path_buf(),
detail: e.to_string(),
})?
}
None => CornerRadii::default(),
};
for (p, v) in fields {
let Value::F32(n) = v else {
return Err(EmitError::UnsupportedComposite {
key: target.key.clone(),
detail: format!("expected a number for {p:?}"),
});
};
let Some(name) = p.first() else {
return Err(EmitError::UnsupportedComposite {
key: target.key.clone(),
detail: "missing radius name".into(),
});
};
let Some(slot) = radii.field_mut(name) else {
return Err(EmitError::UnsupportedComposite {
key: target.key.clone(),
detail: format!("unknown radius `{name}`"),
});
};
// A single `rounding` value applies to all four corners.
*slot = [*n; 4];
}
ron::ser::to_string_pretty(&radii, ron::ser::PrettyConfig::new()).map_err(|e| {
EmitError::UnsupportedComposite {
key: target.key.clone(),
detail: e.to_string(),
}
})
}
other => Err(EmitError::UnsupportedComposite {
key: other.to_string(),
detail: format!(
"composite shape not modelled yet; {} field(s) would be written blind",
fields.len()
),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{parse, resolve};
use tempfile::TempDir;
fn plan_for(src: &str, root: &Path) -> Result<Vec<Planned>, Vec<EmitError>> {
let ast = parse(src).expect("parse");
let resolved = resolve(&ast).expect("resolve");
Emitter::with_root(root).plan(&resolved)
}
fn read(root: &Path, component: &str, key: &str) -> String {
fs::read_to_string(root.join(component).join("v1").join(key))
.unwrap_or_else(|e| panic!("reading {component}/v1/{key}: {e}"))
}
#[test]
fn writes_land_on_the_cosmic_config_path_layout() {
let tmp = TempDir::new().unwrap();
let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
let e = Emitter::with_root(tmp.path());
e.apply(&planned).unwrap();
assert_eq!(
read(tmp.path(), "com.system76.CosmicComp", "autotile"),
"true"
);
}
#[test]
fn scalars_render_as_ron_literals() {
let tmp = TempDir::new().unwrap();
let src = "general {\n autotile = true\n edge_snap_threshold = 12\n}\n\
theme {\n icon_theme = Tela-circle-dracula\n}\n";
let planned = plan_for(src, tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
let root = tmp.path();
assert_eq!(read(root, "com.system76.CosmicComp", "autotile"), "true");
assert_eq!(
read(root, "com.system76.CosmicComp", "edge_snap_threshold"),
"12"
);
assert_eq!(
read(root, "com.system76.CosmicTk", "icon_theme"),
"\"Tela-circle-dracula\""
);
}
/// The end-to-end form of the folding property: both halves must reach disk
/// in one tuple.
#[test]
fn both_gaps_reach_disk_in_one_tuple() {
let tmp = TempDir::new().unwrap();
let planned =
plan_for("general {\n gaps_in = 3\n gaps_out = 8\n}\n", tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
// (outer, inner) — theme.rs:895
for builder in [
"com.system76.CosmicTheme.Dark.Builder",
"com.system76.CosmicTheme.Light.Builder",
] {
assert_eq!(read(tmp.path(), builder, "gaps"), "(8, 3)");
}
}
/// cosmic-config is sparse: an unset key has no file, so a partial
/// projection must fall back to the verified default rather than zero.
#[test]
fn partial_projection_uses_the_verified_default() {
let tmp = TempDir::new().unwrap();
let planned = plan_for("general {\n gaps_in = 5\n}\n", tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
// Default is (0, 8); only inner was set, so outer stays 0.
assert_eq!(
read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"),
"(0, 5)"
);
}
/// Read-modify-write must preserve the half the user did not mention.
#[test]
fn partial_projection_preserves_existing_sibling() {
let tmp = TempDir::new().unwrap();
let dir = tmp
.path()
.join("com.system76.CosmicTheme.Dark.Builder")
.join("v1");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("gaps"), "(20, 4)").unwrap();
let planned = plan_for("general {\n gaps_in = 7\n}\n", tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
// Outer 20 survives; inner becomes 7.
assert_eq!(
read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"),
"(20, 7)"
);
}
#[test]
fn colors_render_with_the_right_component_count() {
let tmp = TempDir::new().unwrap();
let src = "theme {\n accent = rgb(ff0000)\n bg_color = rgba(00ff0080)\n}\n";
let planned = plan_for(src, tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
let b = "com.system76.CosmicTheme.Dark.Builder";
// Option<Srgb>: three components, no alpha.
assert_eq!(
read(tmp.path(), b, "accent"),
"Some((red: 1.0, green: 0.0, blue: 0.0))"
);
// Option<Srgba>: four.
let bg = read(tmp.path(), b, "bg_color");
assert!(
bg.starts_with("Some((red: 0.0, green: 1.0, blue: 0.0, alpha: "),
"{bg}"
);
}
#[test]
fn rounding_sets_all_four_corners_of_radius_m() {
let tmp = TempDir::new().unwrap();
let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
let text = read(
tmp.path(),
"com.system76.CosmicTheme.Dark.Builder",
"corner_radii",
);
let radii: CornerRadii = ron::from_str(&text).expect("round-trips as CornerRadii");
assert_eq!(radii.radius_m, [10.0; 4]);
}
#[test]
fn rounding_preserves_sibling_radii() {
// The other five radii must survive a read-modify-write untouched.
let tmp = TempDir::new().unwrap();
let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
let text = read(
tmp.path(),
"com.system76.CosmicTheme.Dark.Builder",
"corner_radii",
);
let radii: CornerRadii = ron::from_str(&text).unwrap();
let d = CornerRadii::default();
assert_eq!(radii.radius_0, d.radius_0);
assert_eq!(radii.radius_xs, d.radius_xs);
assert_eq!(radii.radius_s, d.radius_s);
assert_eq!(radii.radius_l, d.radius_l);
assert_eq!(radii.radius_xl, d.radius_xl);
}
#[test]
fn corner_radii_defaults_match_upstream() {
// Pinned against cosmic-theme/src/model/corner.rs:20-31. If upstream
// changes these, writing a sparse config would silently shift the theme.
let d = CornerRadii::default();
assert_eq!(d.radius_0, [0.0; 4]);
assert_eq!(d.radius_xs, [4.0; 4]);
assert_eq!(d.radius_s, [8.0; 4]);
assert_eq!(d.radius_m, [16.0; 4]);
assert_eq!(d.radius_l, [32.0; 4]);
assert_eq!(d.radius_xl, [160.0; 4]);
}
#[test]
fn genuinely_unmodelled_composite_is_still_refused() {
// A projected target with no shape handler must error rather than
// write a partial value.
let write = Write {
target: TargetKey {
component: "com.system76.Whatever".into(),
version: 1,
key: "palette".into(),
},
kind: WriteKind::Projected(BTreeMap::from([(
vec!["bright_red".to_string()],
Value::U32(1),
)])),
};
let tmp = TempDir::new().unwrap();
let e = Emitter::with_root(tmp.path());
let res = e.plan(&Resolved {
writes: vec![write],
});
assert!(matches!(
res.unwrap_err()[0],
EmitError::UnsupportedComposite { .. }
),);
}
#[test]
fn unparseable_existing_value_is_refused() {
let tmp = TempDir::new().unwrap();
let dir = tmp
.path()
.join("com.system76.CosmicTheme.Dark.Builder")
.join("v1");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("gaps"), "not ron at all").unwrap();
let errs = plan_for("general {\n gaps_in = 3\n}\n", tmp.path()).unwrap_err();
assert!(
matches!(errs[0], EmitError::Unreadable { .. }),
"{:?}",
errs[0]
);
}
/// Planning must not touch disk — that is what makes emission transactional.
#[test]
fn plan_does_not_write() {
let tmp = TempDir::new().unwrap();
let _ = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
assert!(
fs::read_dir(tmp.path()).unwrap().next().is_none(),
"plan must leave the tree untouched"
);
}
#[test]
fn noop_writes_are_skipped() {
let tmp = TempDir::new().unwrap();
let src = "general {\n autotile = true\n}\n";
let planned = plan_for(src, tmp.path()).unwrap();
assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 1);
// Second run sees identical contents and writes nothing.
let planned = plan_for(src, tmp.path()).unwrap();
assert!(planned[0].is_noop());
assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 0);
}
#[test]
fn previous_contents_are_captured_for_diffing() {
let tmp = TempDir::new().unwrap();
let first = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&first).unwrap();
let second = plan_for("general {\n autotile = false\n}\n", tmp.path()).unwrap();
assert_eq!(second[0].previous.as_deref(), Some("true"));
assert_eq!(second[0].contents, "false");
}
#[test]
fn atomic_write_leaves_no_temp_file() {
let tmp = TempDir::new().unwrap();
let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
let dir = tmp.path().join("com.system76.CosmicComp").join("v1");
let leftovers: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.starts_with(".atomicwrite"))
.collect();
assert!(
leftovers.is_empty(),
"temp files left behind: {leftovers:?}"
);
}
#[test]
fn from_env_honours_xdg_config_home() {
// Uses the documented fallback chain rather than a hardcoded path.
let prev = std::env::var_os("XDG_CONFIG_HOME");
std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-probe");
let e = Emitter::from_env().unwrap();
assert_eq!(e.root(), Path::new("/tmp/xdg-probe/cosmic"));
match prev {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
}
+524
View File
@@ -0,0 +1,524 @@
//! HyDE `hypr.theme` -> `cosmic.conf`.
//!
//! One-way, and into the conf file rather than straight into cosmic-config, so
//! the result is readable and editable before it touches the desktop.
//!
//! The guiding rule is that **nothing is dropped silently**. A HyDE theme
//! contains a good deal that COSMIC has no equivalent for — gradient borders,
//! blur tuning, layer rules — and a converter that quietly ignored them would
//! leave the user wondering why their desktop looks wrong. Every unhandled key
//! is reported with a reason.
use crate::parser::{parse, Item, ParseError, Span};
/// Why a source key did not make it into the output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reason {
/// COSMIC has no equivalent concept.
NoEquivalent(&'static str),
/// Needs a compositor patch that does not exist yet (spec Phase 2).
NeedsCompositorPatch(&'static str),
/// Belongs to another program entirely; copied verbatim, not translated.
DifferentProgram(&'static str),
/// Translated, but with a loss worth knowing about.
Lossy(String),
}
impl Reason {
pub fn describe(&self) -> String {
match self {
Reason::NoEquivalent(d) => format!("no COSMIC equivalent: {d}"),
Reason::NeedsCompositorPatch(d) => format!("needs a cosmic-comp patch: {d}"),
Reason::DifferentProgram(d) => format!("handled by another program: {d}"),
Reason::Lossy(d) => format!("translated with loss: {d}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
pub key: String,
pub value: String,
pub reason: Reason,
pub span: Span,
}
#[derive(Debug, Default)]
pub struct Import {
/// Generated `cosmic.conf` text.
pub conf: String,
/// Everything that did not translate cleanly.
pub notes: Vec<Note>,
/// The theme's `$ICON_THEME`, if it names one.
///
/// Also present in `conf` as `theme.icon_theme`, but repeated here as a
/// field because `assets.rs` needs it to generate rofi's `local.rasi` and
/// re-parsing the text this function just rendered to get it back would be
/// absurd.
pub icon_theme: Option<String>,
}
impl Import {
/// Keys that produced no output at all, as opposed to lossy translations.
pub fn dropped(&self) -> impl Iterator<Item = &Note> {
self.notes
.iter()
.filter(|n| !matches!(n.reason, Reason::Lossy(_)))
}
}
/// HyDE prefixes each `.theme` file with a destination line such as
/// `$HOME/.config/hypr/themes/theme.conf|> $HOME/.../colors.conf`.
///
/// It is metadata for HyDE's own installer, not config, and it has no `=`, so
/// the parser would reject the file outright. Strip it before parsing.
fn strip_hyde_header(src: &str) -> &str {
let mut lines = src.lines();
let Some(first) = lines.next() else {
return src;
};
let is_destination_header =
!first.contains('=') && (first.contains("|>") || first.contains('|'));
if is_destination_header {
// Preserve line numbering by keeping the newline count intact: callers
// report spans against the stripped text, so re-add a blank line.
match src.find('\n') {
Some(i) => &src[i + 1..],
None => "",
}
} else {
src
}
}
/// First colour of a possibly-gradient Hyprland border spec.
/// `rgba(ca9ee6ff) rgba(f2d5cfff) 45deg` -> `ca9ee6`.
fn first_color_rgb(value: &str) -> Option<String> {
let token = value.split_whitespace().next()?;
let inner = token
.strip_prefix("rgba(")
.or_else(|| token.strip_prefix("rgb("))?
.strip_suffix(')')?;
let hex = inner.trim_start_matches('#');
if hex.len() >= 6 && hex[..6].chars().all(|c| c.is_ascii_hexdigit()) {
Some(hex[..6].to_string())
} else {
None
}
}
fn is_gradient(value: &str) -> bool {
value.split_whitespace().count() > 1
}
/// Flatten to dotted keys, keeping variables separate — HyDE carries
/// `$GTK_THEME` / `$ICON_THEME` as variables rather than config keys.
fn walk(
items: &[Item],
prefix: &str,
out: &mut Vec<(String, String, Span)>,
vars: &mut Vec<(String, String, Span)>,
) {
for item in items {
match item {
Item::Section { name, items } => {
let next = if prefix.is_empty() {
name.value.clone()
} else {
format!("{prefix}.{}", name.value)
};
walk(items, &next, out, vars);
}
Item::Assign { key, value } => {
let dotted = if prefix.is_empty() {
key.value.clone()
} else {
format!("{prefix}.{}", key.value)
};
out.push((dotted, value.value.clone(), key.span));
}
Item::VarDef { name, value } => {
vars.push((name.value.clone(), value.value.clone(), name.span));
}
Item::Source { .. } => {}
}
}
}
/// Translate a HyDE `hypr.theme` into a `cosmic.conf`.
pub fn import_hypr_theme(src: &str, theme_name: &str) -> Result<Import, ParseError> {
let body = strip_hyde_header(src);
let ast = parse(body)?;
let mut keys = Vec::new();
let mut vars = Vec::new();
walk(&ast.items, "", &mut keys, &mut vars);
let mut general: Vec<(String, String)> = Vec::new();
let mut decoration: Vec<(String, String)> = Vec::new();
let mut theme: Vec<(String, String)> = Vec::new();
let mut notes = Vec::new();
let mut icon_theme = None;
for (name, value, span) in &vars {
match name.as_str() {
"ICON_THEME" => {
theme.push(("icon_theme".into(), value.clone()));
icon_theme = Some(value.clone());
}
"COLOR_SCHEME" => {
let mode = if value.contains("light") {
"light"
} else {
"dark"
};
theme.push(("mode".into(), mode.into()));
}
"GTK_THEME" => notes.push(Note {
key: format!("${name}"),
value: value.clone(),
reason: Reason::DifferentProgram(
"GTK theme applies to GTK apps directly; COSMIC apps use cosmic-theme",
),
span: *span,
}),
_ => {}
}
}
for (key, value, span) in &keys {
let note = |reason| Note {
key: key.clone(),
value: value.clone(),
reason,
span: *span,
};
match key.as_str() {
"general.gaps_in" => general.push(("gaps_in".into(), value.clone())),
"general.gaps_out" => general.push(("gaps_out".into(), value.clone())),
"decoration.rounding" => decoration.push(("rounding".into(), value.clone())),
// Border colour is the closest thing a HyDE theme has to an accent.
"general.col.active_border" => match first_color_rgb(value) {
Some(hex) => {
theme.push(("accent".into(), format!("rgb({hex})")));
if is_gradient(value) {
notes.push(note(Reason::Lossy(format!(
"used first stop rgb({hex}) as the accent; COSMIC's active_hint \
is a solid colour with no gradient or angle"
))));
}
}
None => notes.push(note(Reason::NoEquivalent("unrecognised colour syntax"))),
},
"general.col.inactive_border"
| "group.col.border_active"
| "group.col.border_inactive"
| "group.col.border_locked_active"
| "group.col.border_locked_inactive" => {
notes.push(note(Reason::NoEquivalent(
"COSMIC draws a single active hint; per-state border colours do not exist",
)));
}
"general.border_size" => notes.push(note(Reason::NoEquivalent(
"COSMIC's active_hint is a boolean, not a width",
))),
"general.layout" => notes.push(note(Reason::NoEquivalent(
"cosmic-comp uses a BSP tiler; dwindle/master are not available",
))),
"general.resize_on_border" => {
notes.push(note(Reason::NoEquivalent("no equivalent setting")))
}
k if k.starts_with("decoration.blur") => {
notes.push(note(Reason::NeedsCompositorPatch(
"COSMIC blur is client-requested via \
ext-background-effect; rule-driven blur is spec Phase 2",
)))
}
k if k.starts_with("decoration.shadow") => notes.push(note(
Reason::NeedsCompositorPatch("shadow.frag exists but is not configurable yet"),
)),
"decoration.active_opacity" | "decoration.inactive_opacity" => notes.push(note(
Reason::NeedsCompositorPatch("window opacity is not configurable yet"),
)),
"layerrule" => notes.push(note(Reason::DifferentProgram(
"layer rules target the bar; waybar is configured directly",
))),
"exec" => notes.push(note(Reason::DifferentProgram(
"HyDE runs gsettings here; icon and GTK themes are handled above",
))),
_ => notes.push(note(Reason::NoEquivalent("unrecognised key"))),
}
}
Ok(Import {
conf: render_conf(theme_name, &general, &decoration, &theme, &notes),
notes,
icon_theme,
})
}
fn render_conf(
theme_name: &str,
general: &[(String, String)],
decoration: &[(String, String)],
theme: &[(String, String)],
notes: &[Note],
) -> String {
let mut out = format!(
"# Generated by `cosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\
# Edit freely — this file is the source of truth; cosmic-settings changes\n\
# are overwritten on the next `cosmic-conf apply`.\n"
);
let dropped: Vec<&Note> = notes
.iter()
.filter(|n| !matches!(n.reason, Reason::Lossy(_)))
.collect();
if !dropped.is_empty() {
out.push_str(&format!(
"#\n# {} setting(s) from the source theme were not translated.\n\
# Run with --report to see them.\n",
dropped.len()
));
}
let section = |name: &str, rows: &[(String, String)], out: &mut String| {
if rows.is_empty() {
return;
}
out.push_str(&format!("\n{name} {{\n"));
let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (k, v) in rows {
out.push_str(&format!(" {k:<width$} = {v}\n"));
}
out.push_str("}\n");
};
section("general", general, &mut out);
section("decoration", decoration, &mut out);
section("theme", theme, &mut out);
out
}
/// Human-readable report of everything that did not translate cleanly.
pub fn render_report(import: &Import) -> String {
if import.notes.is_empty() {
return "Everything in the source theme translated cleanly.\n".into();
}
let mut out = String::new();
let lossy: Vec<&Note> = import
.notes
.iter()
.filter(|n| matches!(n.reason, Reason::Lossy(_)))
.collect();
let dropped: Vec<&Note> = import.dropped().collect();
if !lossy.is_empty() {
out.push_str("Translated with loss:\n");
for n in &lossy {
out.push_str(&format!(
" {} = {}\n {}\n",
n.key,
n.value,
n.reason.describe()
));
}
}
if !dropped.is_empty() {
if !lossy.is_empty() {
out.push('\n');
}
out.push_str("Not translated:\n");
for n in &dropped {
out.push_str(&format!(
" {} = {}\n {}\n",
n.key,
n.value,
n.reason.describe()
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The genuine Catppuccin-Mocha `hypr.theme` from HyDE-Project/hyde-themes,
/// verbatim including its destination header. Synthetic fixtures would miss
/// the header, the colon-keys and the gradient syntax.
const HYDE_CATPPUCCIN: &str = r#"$HOME/.config/hypr/themes/theme.conf|> $HOME/.config/hypr/themes/colors.conf
# // P̳r̳a̳s̳a̳n̳t̳h̳ R̳a̳n̳g̳a̳n̳
$GTK_THEME=Catppuccin-Mocha
$ICON_THEME = Tela-circle-dracula
$COLOR_SCHEME = prefer-dark
exec = gsettings set org.gnome.desktop.interface icon-theme $ICON_THEME
general {
gaps_in = 3
gaps_out = 8
border_size = 2
col.active_border = rgba(ca9ee6ff) rgba(f2d5cfff) 45deg
col.inactive_border = rgba(b4befecc) rgba(6c7086cc) 45deg
layout = dwindle
resize_on_border = true
}
group {
col.border_active = rgba(ca9ee6ff) rgba(f2d5cfff) 45deg
col.border_inactive = rgba(b4befecc) rgba(6c7086cc) 45deg
}
decoration {
rounding = 10
shadow:enabled = false
blur {
enabled = yes
size = 6
passes = 3
}
}
layerrule = blur,waybar
"#;
fn import() -> Import {
import_hypr_theme(HYDE_CATPPUCCIN, "Catppuccin Mocha").expect("real HyDE theme must parse")
}
#[test]
fn real_hyde_theme_parses_despite_its_destination_header() {
// The header has no `=` and would otherwise be a parse error.
let _ = import();
}
#[test]
fn geometry_is_translated() {
let c = import().conf;
assert!(c.contains("gaps_in = 3"), "{c}");
assert!(c.contains("gaps_out = 8"), "{c}");
assert!(c.contains("rounding = 10"), "{c}");
}
#[test]
fn accent_comes_from_the_first_gradient_stop() {
let c = import().conf;
assert!(c.contains("accent"), "{c}");
assert!(c.contains("rgb(ca9ee6)"), "{c}");
}
#[test]
fn gradient_loss_is_reported_not_silent() {
let i = import();
let lossy: Vec<_> = i
.notes
.iter()
.filter(|n| matches!(n.reason, Reason::Lossy(_)))
.collect();
assert_eq!(lossy.len(), 1, "{:?}", i.notes);
assert_eq!(lossy[0].key, "general.col.active_border");
assert!(lossy[0].reason.describe().contains("gradient"));
}
#[test]
fn icon_theme_and_color_scheme_come_from_variables() {
let c = import().conf;
assert!(c.contains("icon_theme = Tela-circle-dracula"), "{c}");
assert!(c.contains("mode"), "{c}");
assert!(c.contains("dark"), "{c}");
}
#[test]
fn blur_is_flagged_as_needing_a_compositor_patch() {
let i = import();
let blur: Vec<_> = i
.notes
.iter()
.filter(|n| n.key.starts_with("decoration.blur"))
.collect();
assert!(!blur.is_empty(), "blur settings must be reported");
assert!(blur
.iter()
.all(|n| matches!(n.reason, Reason::NeedsCompositorPatch(_))));
}
#[test]
fn unsupported_concepts_are_each_reported() {
let i = import();
let keys: Vec<&str> = i.dropped().map(|n| n.key.as_str()).collect();
for expected in [
"general.border_size",
"general.layout",
"general.col.inactive_border",
"group.col.border_active",
"layerrule",
] {
assert!(
keys.contains(&expected),
"`{expected}` missing from {keys:?}"
);
}
}
#[test]
fn nothing_is_dropped_without_a_note() {
// Every source key must either appear in the output or carry a note.
let i = import();
let translated = ["general.gaps_in", "general.gaps_out", "decoration.rounding"];
let noted: Vec<&str> = i.notes.iter().map(|n| n.key.as_str()).collect();
let body = strip_hyde_header(HYDE_CATPPUCCIN);
let ast = parse(body).unwrap();
let (mut keys, mut vars) = (Vec::new(), Vec::new());
walk(&ast.items, "", &mut keys, &mut vars);
for (k, _, _) in &keys {
assert!(
translated.contains(&k.as_str()) || noted.contains(&k.as_str()),
"`{k}` was neither translated nor reported"
);
}
}
#[test]
fn generated_conf_is_valid_input_to_our_own_parser() {
// The importer must not emit something `apply` cannot read.
let c = import().conf;
parse(&c).expect("generated cosmic.conf must parse");
}
#[test]
fn generated_conf_resolves_against_the_registry() {
// Stronger: every key it emits must actually exist in the schema.
let c = import().conf;
let ast = parse(&c).unwrap();
crate::resolve(&ast).expect("generated conf must resolve cleanly");
}
#[test]
fn report_separates_lossy_from_dropped() {
let r = render_report(&import());
assert!(r.contains("Translated with loss:"), "{r}");
assert!(r.contains("Not translated:"), "{r}");
}
#[test]
fn header_without_pipe_is_not_stripped() {
let src = "general {\n gaps_in = 4\n}\n";
let i = import_hypr_theme(src, "t").unwrap();
// Single key, so no alignment padding.
assert!(i.conf.contains("gaps_in = 4"), "{}", i.conf);
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Compiles a single Hyprland-idiom config file into the cosmic-config tree.
//!
//! The pipeline is `parser -> schema -> resolve -> emit`, and it is transactional:
//! `resolve` validates everything before `emit` writes anything, so a malformed
//! file leaves the desktop untouched rather than half-applied.
//!
//! `parser`, `schema` and `resolve` are pure and depend on nothing
//! COSMIC-specific, which keeps the hard logic testable without a compositor
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
pub mod assets;
pub mod bind;
pub mod emit;
pub mod import;
pub mod parser;
pub mod resolve;
pub mod schema;
pub mod watch;
pub use bind::{parse_bind, Bind};
pub use emit::{EmitError, Emitter, Planned};
pub use import::{import_hypr_theme, render_report, Import};
pub use parser::{parse, Ast, ParseError, Span};
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
/// Render a diagnostic against source text, cargo-style.
pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String {
let line = source
.lines()
.nth(span.line.saturating_sub(1))
.unwrap_or("");
let gutter = span.line.to_string().len();
let pad = " ".repeat(gutter);
let caret = " ".repeat(span.col.saturating_sub(1)) + &"^".repeat(span.len.max(1));
let mut out = format!(
"error: {message}\n\
{pad}--> cosmic.conf:{}:{}\n\
{pad} |\n\
{} | {line}\n\
{pad} | {caret}",
span.line, span.col, span.line
);
if let Some(help) = help {
out.push_str(&format!(" {help}"));
}
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn end_to_end_valid_config_produces_writes() {
let src = "\
$accent = rgb(6b9fed)
$gap = 4
general {
gaps_in = $gap
gaps_out = $gap * 2
autotile = true
}
theme {
accent = $accent
}
";
let ast = parse(src).expect("parse");
let r = resolve(&ast).expect("resolve");
assert!(!r.writes.is_empty());
// gaps fold per builder; accent fans out to both; autotile is direct.
let gaps: Vec<_> = r.writes.iter().filter(|w| w.target.key == "gaps").collect();
assert_eq!(gaps.len(), 2);
let accent: Vec<_> = r
.writes
.iter()
.filter(|w| w.target.key == "accent")
.collect();
assert_eq!(accent.len(), 2);
}
#[test]
fn diagnostic_rendering_points_at_the_offending_token() {
let src = "general {\n gaps_inn = 8\n}\n";
let ast = parse(src).unwrap();
let diags = resolve(&ast).unwrap_err();
let out = render_diagnostic(
src,
diags[0].span,
&diags[0].message,
diags[0].help.as_deref(),
);
assert!(out.contains("unknown key"), "{out}");
assert!(out.contains("cosmic.conf:2:5"), "{out}");
assert!(out.contains("^^^^^^^^"), "{out}");
assert!(out.contains("did you mean"), "{out}");
}
}
+432
View File
@@ -0,0 +1,432 @@
//! `cosmic-conf` — compile a Hyprland-idiom config file into cosmic-config.
//!
//! Exit codes: 0 success, 1 config error (nothing written), 2 usage error.
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch};
const USAGE: &str = "\
cosmic-conf — compile cosmic.conf into the cosmic-config tree
USAGE:
cosmic-conf apply [--diff] [--config <path>]
cosmic-conf watch [--config <path>]
cosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
[--assets [--source <dir>] [--overwrite] [--dry-run]]
COMMANDS:
apply Compile the config once and exit
watch Stay running and recompile on every edit, to the config
and to anything it sources. A malformed edit is reported
and waited past, not fatal.
import-theme Translate a HyDE theme into config keys
OPTIONS:
--diff Show what would change without writing anything
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
--out <path> Write the generated cosmic.conf here (default: stdout)
--report Print everything that did not translate cleanly
--assets Also install wallpapers, GTK/icon themes and the
waybar/rofi/kitty theme files that sit beside hypr.theme
--source <dir> The theme repo's Source/ directory holding the GTK and
icon tarballs (default: found by searching upward)
--overwrite Replace assets that are already installed
--dry-run With --assets, list what would be installed and stop
-h, --help Show this help
";
/// HyDE keeps GTK and icon tarballs in a `Source/` directory at the root of
/// the theme repo, four levels above the theme folder
/// (`Configs/.config/hyde/themes/<Name>/`). Searching upward rather than
/// hardcoding that depth means a theme unpacked at a different depth, or one
/// vendored into another tree, still works.
fn find_source_dir(theme_dir: &std::path::Path) -> Option<PathBuf> {
theme_dir
.ancestors()
.take(6)
.map(|a| a.join("Source"))
.find(|c| c.is_dir())
}
/// Refuse arguments the caller does not understand.
///
/// `apply` writes to the config tree, so an argument it does not recognise has
/// to stop it rather than be skipped: `--diff-only` instead of `--diff`, or a
/// config path given positionally, would otherwise apply to the *default*
/// config while looking like it had done what was asked. This is a
/// hand-rolled check rather than a dependency because the whole surface is six
/// flags, and an argument parser that silently ignores the unknown is exactly
/// the behaviour being removed.
///
/// `flags` take no value; `valued` consume the argument after them.
fn reject_unknown(args: &[String], flags: &[&str], valued: &[&str]) -> Result<(), String> {
let mut i = 0;
while i < args.len() {
let a = &args[i];
if valued.contains(&a.as_str()) {
i += 2;
} else if flags.contains(&a.as_str()) || a == "-h" || a == "--help" {
// `--help` is accepted by every subcommand, so callers do not have
// to list it; `main` has already acted on it by this point.
i += 1;
} else if let Some(name) = a.strip_prefix("--") {
return Err(format!("error: unknown option `--{name}`"));
} else {
return Err(format!("error: unexpected argument `{a}`"));
}
}
Ok(())
}
fn default_config_path() -> Option<PathBuf> {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(x) if !x.is_empty() => PathBuf::from(x),
_ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
};
Some(base.join("hyprcosmic").join("cosmic.conf"))
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") {
print!("{USAGE}");
return ExitCode::SUCCESS;
}
if args[0] == "import-theme" {
return match run_import(&args[1..]) {
Ok(msg) => {
print!("{msg}");
ExitCode::SUCCESS
}
Err(msg) => {
eprint!("{msg}");
ExitCode::from(1)
}
};
}
let command = args[0].as_str();
if !matches!(command, "apply" | "watch") {
eprintln!("error: unknown command `{command}`\n\n{USAGE}");
return ExitCode::from(2);
}
// `--diff` belongs to `apply` alone: a daemon whose whole job is to notice
// a change and write it has nothing to do with a mode that declines to
// write. Passing it to `watch` is an error rather than a no-op, for the
// same reason `--diff-only` is.
let flags: &[&str] = if command == "apply" { &["--diff"] } else { &[] };
if let Err(msg) = reject_unknown(&args[1..], flags, &["--config"]) {
eprintln!("{msg}\n\n{USAGE}");
return ExitCode::from(2);
}
let diff_only = args.iter().any(|a| a == "--diff");
let config_path = match args.iter().position(|a| a == "--config") {
Some(i) => match args.get(i + 1) {
Some(p) => PathBuf::from(p),
None => {
eprintln!("error: --config needs a path");
return ExitCode::from(2);
}
},
None => match default_config_path() {
Some(p) => p,
None => {
eprintln!("error: cannot determine config path (no HOME or XDG_CONFIG_HOME)");
return ExitCode::from(2);
}
},
};
if command == "watch" {
return match run_watch(&config_path) {
Ok(()) => ExitCode::SUCCESS,
Err(msg) => {
eprint!("{msg}");
ExitCode::from(1)
}
};
}
match run(&config_path, diff_only) {
Ok(msg) => {
println!("{msg}");
ExitCode::SUCCESS
}
Err(msg) => {
eprint!("{msg}");
ExitCode::from(1)
}
}
}
fn run(config_path: &Path, diff_only: bool) -> Result<String, String> {
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
// Through `watch::compile` rather than parse/resolve/plan inline, because
// that is the only path that expands `source`. Doing it by hand here meant
// `resolve` never saw the included text -- `flatten` drops `Item::Source`
// -- so a sourced file was silently ignored by `apply` while `watch`
// honoured it. An include that works in one and vanishes in the other is
// worse than one that is unsupported in both.
let compiled = watch::compile(config_path, &emitter).map_err(|e| e.to_string())?;
let planned = compiled.planned;
let changes: Vec<_> = planned.iter().filter(|p| !p.is_noop()).collect();
if diff_only {
if changes.is_empty() {
return Ok("No changes.".into());
}
let mut out = String::new();
for p in &changes {
let rel = p
.path
.strip_prefix(emitter.root())
.unwrap_or(&p.path)
.display();
out.push_str(&format!("~ {rel}\n"));
match &p.previous {
Some(prev) => out.push_str(&format!(" - {}\n", prev.trim())),
None => out.push_str(" - (unset)\n"),
}
out.push_str(&format!(" + {}\n", p.contents.trim()));
}
out.push_str(&format!("\n{} file(s) would change.", changes.len()));
return Ok(out);
}
let written = emitter
.apply(&planned)
.map_err(|e| format!("error: {e}\n"))?;
Ok(format!(
"Applied {written} change(s) to {}.",
emitter.root().display()
))
}
/// Block, recompiling on every edit, until the watcher itself stops.
///
/// Returns nothing to print on success because there is no success to report
/// until it is over: progress goes to stderr as it happens, from inside the
/// loop. A config error is not an error here either -- `watch` reports a
/// malformed edit and waits for the next one, which is the whole point of
/// leaving it running -- so the only failure that reaches this function is the
/// notify machinery failing to start.
fn run_watch(config_path: &Path) -> Result<(), String> {
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
watch::watch(config_path, &emitter).map_err(|e| format!("{e}\n"))
}
fn run_import(args: &[String]) -> Result<String, String> {
let Some(src_path) = args.first().filter(|a| !a.starts_with("--")) else {
return Err(format!("error: import-theme needs a path\n\n{USAGE}"));
};
reject_unknown(
&args[1..],
&["--report", "--assets", "--overwrite", "--dry-run"],
&["--out", "--source"],
)
.map_err(|msg| format!("{msg}\n\n{USAGE}"))?;
let out_path = args
.iter()
.position(|a| a == "--out")
.and_then(|i| args.get(i + 1))
.map(PathBuf::from);
let want_report = args.iter().any(|a| a == "--report");
let source = std::fs::read_to_string(src_path)
.map_err(|e| format!("error: cannot read {src_path}: {e}\n"))?;
// HyDE names a theme by its containing directory.
let name = PathBuf::from(src_path)
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "imported".into());
let imported = import::import_hypr_theme(&source, &name)
.map_err(|e| render_diagnostic(&source, e.span, &e.message, None))?;
let mut out = String::new();
match out_path {
Some(p) => {
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("error: cannot create {}: {e}\n", dir.display()))?;
}
std::fs::write(&p, &imported.conf)
.map_err(|e| format!("error: cannot write {}: {e}\n", p.display()))?;
out.push_str(&format!("Wrote {}\n", p.display()));
if let Some(hint) = unsourced_hint(&p) {
out.push_str(&hint);
}
}
None => out.push_str(&imported.conf),
}
let dropped = imported.dropped().count();
if want_report {
out.push('\n');
out.push_str(&import::render_report(&imported));
} else if dropped > 0 {
out.push_str(&format!(
"\n{dropped} setting(s) did not translate. Re-run with --report for details.\n"
));
}
if args.iter().any(|a| a == "--assets") {
out.push('\n');
out.push_str(&install_assets(
src_path,
&name,
imported.icon_theme.as_deref(),
args,
)?);
}
Ok(out)
}
/// Warn when the file just written is not reachable from its sibling
/// cosmic.conf, and say what to add.
///
/// A theme lives in its own file so that re-importing cannot clobber the
/// keybindings around it, but that only works if something sources it.
/// Writing an inert file and reporting success is the worst of both: the tool
/// looks like it worked and the desktop does not change.
///
/// The match is textual and deliberately loose -- it is looking for evidence
/// that the user already knows about the file, not parsing the config. A false
/// negative costs one redundant hint; a false positive would hide a real
/// problem, so the substring searched for is the filename itself.
fn unsourced_hint(written: &Path) -> Option<String> {
let dir = written.parent()?;
let name = written.file_name()?.to_string_lossy().to_string();
let main = dir.join("cosmic.conf");
// Nothing to say when the theme *is* the config, or there is no config yet
// to add a line to: `apply` will be pointed at this file directly.
if main == written || !main.exists() {
return None;
}
let text = std::fs::read_to_string(&main).ok()?;
if text
.lines()
.any(|l| l.trim_start().starts_with("source") && l.contains(&name))
{
return None;
}
Some(format!(
"\nNothing sources it yet, so `apply` will ignore it. Add this to {}:\n\n source = {}\n",
main.display(),
written.display(),
))
}
/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, the
/// `.theme` files belonging to waybar, rofi and kitty, and the small rofi
/// files HyprCosmic has to compose itself.
///
/// Separate from the conf translation because it is separate in kind — almost
/// none of it is translated, only placed — and because it writes outside the
/// cosmic-config tree, which every other path in this tool does not.
///
/// `icon_theme` comes back out of the conf translation rather than being read
/// from the theme directory again, because that is where the `$ICON_THEME`
/// variable was already resolved.
fn install_assets(
src_path: &str,
name: &str,
icon_theme: Option<&str>,
args: &[String],
) -> Result<String, String> {
let theme_dir = PathBuf::from(src_path)
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("error: {src_path} has no parent directory\n"))?;
let source_dir = match args.iter().position(|a| a == "--source") {
Some(i) => match args.get(i + 1) {
Some(p) => Some(PathBuf::from(p)),
None => return Err(format!("error: --source needs a path\n\n{USAGE}")),
},
None => find_source_dir(&theme_dir),
};
let installer = assets::Installer::from_env().map_err(|e| format!("error: {e}\n"))?;
let plan = installer
.plan(
&theme_dir,
source_dir.as_deref(),
name,
icon_theme,
args.iter().any(|a| a == "--overwrite"),
)
.map_err(|errors| {
errors
.iter()
.map(|e| format!("error: {e}\n"))
.collect::<String>()
})?;
if args.iter().any(|a| a == "--dry-run") {
return Ok(assets::render_plan(&plan));
}
let report = installer
.apply(&plan)
.map_err(|e| format!("error: {e}\n"))?;
Ok(assets::render_report(&plan, &report))
}
#[cfg(test)]
mod tests {
use super::*;
fn args(s: &[&str]) -> Vec<String> {
s.iter().map(|a| a.to_string()).collect()
}
#[test]
fn known_flags_and_their_values_are_accepted() {
let a = args(&["--diff", "--config", "/etc/cosmic.conf"]);
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
}
#[test]
fn a_misspelt_flag_is_refused_rather_than_skipped() {
// The bug this exists to prevent: `--diff-only` used to be ignored, so
// `apply` wrote for real while the caller believed it was a dry run.
let a = args(&["--diff-only"]);
let err = reject_unknown(&a, &["--diff"], &["--config"]).unwrap_err();
assert!(err.contains("--diff-only"), "{err}");
}
#[test]
fn a_positional_path_is_refused_because_it_would_be_ignored() {
let a = args(&["/home/me/.config/hyprcosmic/cosmic.conf"]);
let err = reject_unknown(&a, &["--diff"], &["--config"]).unwrap_err();
assert!(err.contains("unexpected argument"), "{err}");
}
#[test]
fn a_value_that_looks_like_a_flag_is_still_a_value() {
// `--config --diff` is a user error, but it is the *next* argument's
// job to be a path; consuming it here keeps the rule simple and
// matches what the position-based lookup below actually does.
let a = args(&["--config", "--diff"]);
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
}
#[test]
fn a_trailing_valued_flag_with_no_value_is_not_a_panic() {
let a = args(&["--config"]);
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
}
}
+345
View File
@@ -0,0 +1,345 @@
//! `cosmic.conf` text -> AST.
//!
//! Hyprland-idiom, line-based grammar:
//!
//! ```text
//! # comment
//! $var = value
//! section {
//! key = value
//! nested { key = value }
//! }
//! bind = SUPER, Q, close # repeatable keys are kept in order
//! source = ~/other.conf
//! ```
//!
//! Values are kept as raw strings here; typing happens in `resolve`, which needs
//! the schema to know what a value should be.
use std::fmt;
/// Byte-independent source position. Line and column are 1-based so they match
/// what an editor shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub line: usize,
pub col: usize,
pub len: usize,
}
impl Span {
pub fn new(line: usize, col: usize, len: usize) -> Self {
Self { line, col, len }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Spanned<T> {
pub value: T,
pub span: Span,
}
impl<T> Spanned<T> {
pub fn new(value: T, span: Span) -> Self {
Self { value, span }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Item {
/// `$name = value`
VarDef {
name: Spanned<String>,
value: Spanned<String>,
},
/// `key = value` inside the current section
Assign {
key: Spanned<String>,
value: Spanned<String>,
},
/// `name { .. }`
Section {
name: Spanned<String>,
items: Vec<Item>,
},
/// `source = path`
Source { path: Spanned<String> },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Ast {
pub items: Vec<Item>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
pub message: String,
pub span: Span,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}: {}", self.span.line, self.span.col, self.message)
}
}
impl std::error::Error for ParseError {}
/// Strip a trailing `#` comment, respecting nothing else — the grammar has no
/// string literals, so there is no quoting to honour.
fn strip_comment(line: &str) -> &str {
match line.find('#') {
Some(i) => &line[..i],
None => line,
}
}
/// Column (1-based) of the first non-whitespace byte.
fn indent_col(line: &str) -> usize {
line.len() - line.trim_start().len() + 1
}
pub fn parse(input: &str) -> Result<Ast, ParseError> {
let mut cursor = Cursor {
lines: input.lines().collect(),
idx: 0,
};
let items = parse_items(&mut cursor, 0)?;
Ok(Ast { items })
}
struct Cursor<'a> {
lines: Vec<&'a str>,
idx: usize,
}
/// Parse items until EOF (`depth == 0`) or a closing brace.
fn parse_items(cur: &mut Cursor, depth: usize) -> Result<Vec<Item>, ParseError> {
let mut items = Vec::new();
while cur.idx < cur.lines.len() {
let raw = cur.lines[cur.idx];
let line_no = cur.idx + 1;
let content = strip_comment(raw).trim_end();
let trimmed = content.trim();
if trimmed.is_empty() {
cur.idx += 1;
continue;
}
if trimmed == "}" {
if depth == 0 {
return Err(ParseError {
message: "unmatched `}`".into(),
span: Span::new(line_no, indent_col(content), 1),
});
}
cur.idx += 1;
return Ok(items);
}
// `name {` opens a section. A one-line `name { .. }` is not supported;
// keeping the grammar strictly line-based keeps spans honest.
if let Some(name) = trimmed.strip_suffix('{') {
let name = name.trim();
if name.is_empty() {
return Err(ParseError {
message: "section is missing a name".into(),
span: Span::new(line_no, indent_col(content), 1),
});
}
let span = Span::new(line_no, indent_col(content), name.len());
cur.idx += 1;
let inner = parse_items(cur, depth + 1)?;
items.push(Item::Section {
name: Spanned::new(name.to_string(), span),
items: inner,
});
continue;
}
let Some(eq) = content.find('=') else {
return Err(ParseError {
message: format!("expected `key = value`, found `{trimmed}`"),
span: Span::new(line_no, indent_col(content), trimmed.len()),
});
};
let key_raw = &content[..eq];
let val_raw = &content[eq + 1..];
let key = key_raw.trim();
let value = val_raw.trim();
if key.is_empty() {
return Err(ParseError {
message: "assignment is missing a key".into(),
span: Span::new(line_no, 1, eq.max(1)),
});
}
let key_col = indent_col(content);
let key_span = Span::new(line_no, key_col, key.len());
// Column of the value = everything before it, plus its own leading trim.
let val_col = eq + 2 + (val_raw.len() - val_raw.trim_start().len());
let val_span = Span::new(line_no, val_col, value.len());
let item = if let Some(var) = key.strip_prefix('$') {
if var.is_empty() {
return Err(ParseError {
message: "variable is missing a name after `$`".into(),
span: key_span,
});
}
Item::VarDef {
name: Spanned::new(var.to_string(), key_span),
value: Spanned::new(value.to_string(), val_span),
}
} else if key == "source" {
Item::Source {
path: Spanned::new(value.to_string(), val_span),
}
} else {
Item::Assign {
key: Spanned::new(key.to_string(), key_span),
value: Spanned::new(value.to_string(), val_span),
}
};
items.push(item);
cur.idx += 1;
}
if depth != 0 {
let last = cur.lines.len().max(1);
return Err(ParseError {
message: "unclosed section: expected `}`".into(),
span: Span::new(last, 1, 1),
});
}
Ok(items)
}
#[cfg(test)]
mod tests {
use super::*;
fn assign(items: &[Item], key: &str) -> String {
items
.iter()
.find_map(|i| match i {
Item::Assign { key: k, value } if k.value == key => Some(value.value.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("no assignment named `{key}`"))
}
fn section<'a>(items: &'a [Item], name: &str) -> &'a [Item] {
items
.iter()
.find_map(|i| match i {
Item::Section { name: n, items } if n.value == name => Some(items.as_slice()),
_ => None,
})
.unwrap_or_else(|| panic!("no section named `{name}`"))
}
#[test]
fn parses_flat_assignments() {
let ast = parse("autotile = true\nrounding = 10\n").unwrap();
assert_eq!(assign(&ast.items, "autotile"), "true");
assert_eq!(assign(&ast.items, "rounding"), "10");
}
#[test]
fn parses_variables() {
let ast = parse("$accent = rgb(6b9fed)\n").unwrap();
match &ast.items[0] {
Item::VarDef { name, value } => {
assert_eq!(name.value, "accent");
assert_eq!(value.value, "rgb(6b9fed)");
}
other => panic!("expected VarDef, got {other:?}"),
}
}
#[test]
fn parses_nested_sections() {
let src = "decoration {\n rounding = 10\n blur {\n size = 6\n }\n}\n";
let ast = parse(src).unwrap();
let deco = section(&ast.items, "decoration");
assert_eq!(assign(deco, "rounding"), "10");
assert_eq!(assign(section(deco, "blur"), "size"), "6");
}
#[test]
fn strips_comments_but_keeps_values() {
let ast = parse("gaps_in = 3 # inner gap\n# whole line\n").unwrap();
assert_eq!(assign(&ast.items, "gaps_in"), "3");
assert_eq!(ast.items.len(), 1);
}
#[test]
fn source_is_its_own_item() {
let ast = parse("source = ~/.config/hyprcosmic/monitors.conf\n").unwrap();
match &ast.items[0] {
Item::Source { path } => assert_eq!(path.value, "~/.config/hyprcosmic/monitors.conf"),
other => panic!("expected Source, got {other:?}"),
}
}
#[test]
fn repeatable_keys_are_preserved_in_order() {
let ast = parse("bind = SUPER, Return, spawn, kitty\nbind = SUPER, Q, close\n").unwrap();
let binds: Vec<_> = ast
.items
.iter()
.filter_map(|i| match i {
Item::Assign { key, value } if key.value == "bind" => Some(value.value.as_str()),
_ => None,
})
.collect();
assert_eq!(
binds,
vec!["SUPER, Return, spawn, kitty", "SUPER, Q, close"]
);
}
#[test]
fn spans_point_at_the_key() {
let ast = parse("general {\n gaps_inn = 8\n}\n").unwrap();
let inner = section(&ast.items, "general");
match &inner[0] {
Item::Assign { key, .. } => {
assert_eq!(key.span.line, 2);
assert_eq!(key.span.col, 5);
assert_eq!(key.span.len, "gaps_inn".len());
}
other => panic!("expected Assign, got {other:?}"),
}
}
#[test]
fn rejects_unclosed_section() {
let err = parse("general {\n gaps_in = 3\n").unwrap_err();
assert!(err.message.contains("unclosed section"), "{}", err.message);
}
#[test]
fn rejects_unmatched_brace() {
let err = parse("}\n").unwrap_err();
assert!(err.message.contains("unmatched"), "{}", err.message);
}
#[test]
fn rejects_line_without_equals() {
let err = parse("this is not valid\n").unwrap_err();
assert!(
err.message.contains("expected `key = value`"),
"{}",
err.message
);
assert_eq!(err.span.line, 1);
}
}
+783
View File
@@ -0,0 +1,783 @@
//! AST + schema -> validated, folded writes.
//!
//! Two jobs matter here:
//!
//! 1. **Validation is total before anything is emitted.** A malformed file must
//! leave the desktop untouched rather than half-applied, so `resolve` returns
//! every diagnostic it can find and `emit` never sees a partial result.
//! 2. **Projections are folded per target.** Several conf keys can write into
//! one composite cosmic-config value (`gaps_in` and `gaps_out` are two halves
//! of one `(u32, u32)`). Writing them independently would let the second
//! clobber the first, so they are merged into a single write.
use std::collections::BTreeMap;
use crate::bind;
use crate::parser::{Ast, Item, Span, Spanned};
use crate::schema::{self, Entry, Range, Target, Ty};
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Bool(bool),
U32(u32),
F32(f32),
Str(String),
/// `Option<Srgb>` target — no alpha channel.
Rgb(u8, u8, u8),
/// `Option<Srgba>` target — carries alpha.
Rgba(u8, u8, u8, u8),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Diagnostic {
pub message: String,
pub span: Span,
pub help: Option<String>,
}
/// Identifies one cosmic-config value. Ordering is deterministic so emitted
/// writes are stable across runs, which keeps `--diff` output readable.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct TargetKey {
pub component: String,
pub version: u8,
pub key: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum WriteKind {
/// The conf key owns the whole value.
Whole(Value),
/// Field path -> value, folded from every conf key touching this target.
Projected(BTreeMap<Vec<String>, Value>),
/// Pre-rendered RON owning the whole value.
///
/// Used where the target's shape is a collection rather than a scalar, so
/// there is no `Value` to coerce into: keybindings fold many `bind` lines
/// into one map. Rendering happens at the point that understands the shape
/// (`bind::render`) instead of being reconstructed in `emit`.
Verbatim(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Write {
pub target: TargetKey,
pub kind: WriteKind,
}
#[derive(Debug, Default)]
pub struct Resolved {
pub writes: Vec<Write>,
}
/// Flatten the AST into dotted `section.key` paths, dropping `source` items —
/// include expansion happens before `resolve` so that spans stay attributable
/// to the file they came from.
fn flatten(items: &[Item], prefix: &str, out: &mut Vec<(String, Spanned<String>, Span)>) {
for item in items {
match item {
Item::Section { name, items } => {
let next = if prefix.is_empty() {
name.value.clone()
} else {
format!("{prefix}.{}", name.value)
};
flatten(items, &next, out);
}
Item::Assign { key, value } => {
let dotted = if prefix.is_empty() {
key.value.clone()
} else {
format!("{prefix}.{}", key.value)
};
out.push((dotted, value.clone(), key.span));
}
Item::VarDef { .. } | Item::Source { .. } => {}
}
}
}
fn collect_vars(items: &[Item], out: &mut BTreeMap<String, String>) {
for item in items {
match item {
Item::VarDef { name, value } => {
out.insert(name.value.clone(), value.value.clone());
}
Item::Section { items, .. } => collect_vars(items, out),
_ => {}
}
}
}
/// Substitute `$name` occurrences. Longest-name-first avoids `$gap` eating the
/// prefix of `$gaps`.
fn expand_vars(input: &str, vars: &BTreeMap<String, String>) -> String {
if !input.contains('$') {
return input.to_string();
}
let mut names: Vec<&String> = vars.keys().collect();
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
let mut out = input.to_string();
for name in names {
out = out.replace(&format!("${name}"), &vars[name]);
}
out
}
/// Evaluate the tiny arithmetic the format allows: `a * b`, `a + b`, `a - b`.
/// Anything else is returned untouched for the type parser to reject.
fn eval_arith(input: &str) -> String {
for op in ['*', '+', '-'] {
if let Some((l, r)) = input.split_once(op) {
let (l, r) = (l.trim(), r.trim());
if let (Ok(a), Ok(b)) = (l.parse::<f64>(), r.parse::<f64>()) {
let v = match op {
'*' => a * b,
'+' => a + b,
_ => a - b,
};
return if v.fract() == 0.0 {
format!("{}", v as i64)
} else {
format!("{v}")
};
}
}
}
input.to_string()
}
/// Parse `rgb(rrggbb)` or `rgba(rrggbbaa)`.
///
/// Bare `#rrggbb` is deliberately **not** accepted: `#` begins a comment, so the
/// value would be stripped before reaching here. Hyprland makes the same
/// trade-off, and HyDE themes write colours as `rgba(...)`, so nothing is lost.
fn parse_color(raw: &str) -> Option<(u8, u8, u8, u8)> {
let s = raw.trim();
// Both spellings take the same body; the alpha pair is optional either
// way, so `rgb(rrggbbaa)` and `rgba(rrggbb)` are accepted too rather than
// rejected on a technicality.
let inner = s
.strip_prefix("rgba(")
.or_else(|| s.strip_prefix("rgb("))
.and_then(|s| s.strip_suffix(')'))?;
let hex = inner.trim().trim_start_matches('#');
// `len` and the slicing below are both in bytes, so a multi-byte character
// would make `hex[i..i + 2]` split a char boundary and panic. A config
// typo must not crash the compiler.
if !hex.is_ascii() {
return None;
}
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
match hex.len() {
6 => Some((byte(0)?, byte(2)?, byte(4)?, 255)),
8 => Some((byte(0)?, byte(2)?, byte(4)?, byte(6)?)),
_ => None,
}
}
fn coerce(raw: &str, ty: Ty, span: Span) -> Result<Value, Diagnostic> {
let bad = |expected: &str| Diagnostic {
message: format!("expected {expected}, found `{raw}`"),
span,
help: None,
};
match ty {
Ty::Bool => match raw {
"true" | "yes" | "on" | "1" => Ok(Value::Bool(true)),
"false" | "no" | "off" | "0" => Ok(Value::Bool(false)),
_ => Err(bad("a boolean (true/false/yes/no/on/off)")),
},
Ty::U32 => raw
.parse::<u32>()
.map(Value::U32)
.map_err(|_| bad("a non-negative integer")),
Ty::F32 => raw
.parse::<f32>()
.map(Value::F32)
.map_err(|_| bad("a number")),
Ty::Str => Ok(Value::Str(raw.to_string())),
Ty::Rgb => parse_color(raw)
.map(|(r, g, b, _)| Value::Rgb(r, g, b))
.ok_or_else(|| bad("a colour like rgb(6b9fed)")),
Ty::Rgba => parse_color(raw)
.map(|(r, g, b, a)| Value::Rgba(r, g, b, a))
.ok_or_else(|| bad("a colour like rgb(6b9fed) or rgba(6b9fed80)")),
Ty::Mode => match raw {
"dark" => Ok(Value::Bool(true)),
"light" => Ok(Value::Bool(false)),
_ => Err(bad("`dark` or `light`")),
},
Ty::FollowMouse => match raw {
"0" => Ok(Value::Bool(false)),
"1" => Ok(Value::Bool(true)),
// Named separately from the catch-all so the message can say why a
// value that is valid in Hyprland does not work here.
"2" | "3" => Err(Diagnostic {
message: format!("`follow_mouse = {raw}` has no COSMIC equivalent"),
span,
help: Some(
"cosmic-comp has a single focus rather than separate pointer and \
keyboard focus, so it cannot detach them. Use `1` for focus follows \
mouse or `0` for click to focus."
.into(),
),
}),
_ => Err(bad("`0` (click to focus) or `1` (focus follows mouse)")),
},
}
}
fn check_range(v: &Value, range: Option<Range>, span: Span) -> Result<(), Diagnostic> {
let Some(r) = range else { return Ok(()) };
let n = match v {
Value::U32(n) => *n as f64,
Value::F32(n) => *n as f64,
_ => return Ok(()),
};
if n < r.min || n > r.max {
return Err(Diagnostic {
message: format!(
"value {n} is outside the allowed range {}..={}",
r.min, r.max
),
span,
help: None,
});
}
Ok(())
}
/// Resolve an AST against the registry.
///
/// Returns **all** diagnostics rather than the first, so a user fixing a config
/// sees the whole picture in one pass.
pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
let mut vars = BTreeMap::new();
collect_vars(&ast.items, &mut vars);
let mut flat = Vec::new();
flatten(&ast.items, "", &mut flat);
let mut diags = Vec::new();
// (target) -> folded projections, plus whole-value writes kept separate so
// a collision between the two can be reported rather than silently resolved.
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
let mut whole: BTreeMap<TargetKey, (Value, Span)> = BTreeMap::new();
// `bind` is the one repeatable key in the language: many lines fold into a
// single map rather than the last one winning, so it cannot go through the
// schema, which is built around one conf key naming one value.
let mut binds: Vec<(bind::Bind, Span)> = Vec::new();
for (conf, raw_value, key_span) in &flat {
if conf == "bind" {
let expanded = expand_vars(&raw_value.value, &vars);
match bind::parse_bind(&expanded, raw_value.span) {
Ok(b) => {
if let Some((prev, prev_span)) = binds
.iter()
.find(|(o, _)| o.mods == b.mods && o.key == b.key)
{
diags.push(Diagnostic {
message: format!(
"this key combination is already bound to `{}`",
prev.action
),
span: raw_value.span,
help: Some(format!("the earlier bind is on line {}", prev_span.line)),
});
continue;
}
binds.push((b, raw_value.span));
}
Err(e) => diags.push(Diagnostic {
message: e.message,
span: e.span,
help: e.help,
}),
}
continue;
}
let Some(entry) = schema::lookup(conf) else {
diags.push(Diagnostic {
message: format!("unknown key `{conf}`"),
span: *key_span,
help: schema::suggest(conf).map(|s| format!("did you mean `{s}`?")),
});
continue;
};
let expanded = eval_arith(&expand_vars(&raw_value.value, &vars));
let value = match coerce(&expanded, entry.ty, raw_value.span) {
Ok(v) => v,
Err(d) => {
diags.push(d);
continue;
}
};
if let Err(d) = check_range(&value, entry.validate, raw_value.span) {
diags.push(d);
continue;
}
record(
entry,
value,
raw_value.span,
&mut projected,
&mut whole,
&mut diags,
);
}
if !diags.is_empty() {
return Err(diags);
}
let mut writes: Vec<Write> = whole
.into_iter()
.map(|(target, (v, _))| Write {
target,
kind: WriteKind::Whole(v),
})
.collect();
writes.extend(projected.into_iter().map(|(target, fields)| Write {
target,
kind: WriteKind::Projected(fields),
}));
if !binds.is_empty() {
let rendered = bind::render(&binds.iter().map(|(b, _)| b.clone()).collect::<Vec<_>>());
writes.push(Write {
// cosmic-comp merges `custom` over `defaults`
// (cosmic-settings-daemon `config/src/shortcuts/mod.rs`), so writing
// here overrides a stock shortcut without touching the system file.
target: TargetKey {
component: "com.system76.CosmicSettings.Shortcuts".into(),
version: 1,
key: "custom".into(),
},
kind: WriteKind::Verbatim(rendered),
});
}
writes.sort_by(|a, b| a.target.cmp(&b.target));
Ok(Resolved { writes })
}
fn record(
entry: &Entry,
value: Value,
span: Span,
projected: &mut BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>>,
whole: &mut BTreeMap<TargetKey, (Value, Span)>,
diags: &mut Vec<Diagnostic>,
) {
for target in entry.targets {
let tk = TargetKey {
component: target.component().to_string(),
version: target.version(),
key: target.key().to_string(),
};
match target {
Target::Direct { .. } => {
if projected.contains_key(&tk) {
diags.push(Diagnostic {
message: format!(
"`{}` writes all of `{}`, but another key writes one of its fields",
entry.conf, tk.key
),
span,
help: None,
});
continue;
}
whole.insert(tk, (value.clone(), span));
}
Target::Projected { path, .. } => {
if whole.contains_key(&tk) {
diags.push(Diagnostic {
message: format!(
"`{}` writes a field of `{}`, but another key writes the whole value",
entry.conf, tk.key
),
span,
help: None,
});
continue;
}
let fields = projected.entry(tk).or_default();
let path: Vec<String> = path.iter().map(|s| s.to_string()).collect();
fields.insert(path, value.clone());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn resolved(src: &str) -> Resolved {
let ast = parse(src).expect("parse failed");
resolve(&ast).expect("resolve failed")
}
fn errors(src: &str) -> Vec<Diagnostic> {
let ast = parse(src).expect("parse failed");
resolve(&ast).unwrap_err()
}
#[test]
fn binds_fold_into_one_write_against_the_shortcuts_custom_key() {
let r = resolved("bind = SUPER, D, exec, rofi -show drun\nbind = SUPER, Q, killactive\n");
let w: Vec<_> = r
.writes
.iter()
.filter(|w| w.target.component == "com.system76.CosmicSettings.Shortcuts")
.collect();
assert_eq!(w.len(), 1, "every bind belongs to one map");
assert_eq!(w[0].target.key, "custom");
assert_eq!(w[0].target.version, 1);
let WriteKind::Verbatim(ron) = &w[0].kind else {
panic!("expected verbatim RON, got {:?}", w[0].kind);
};
assert!(
ron.contains(r#"(modifiers: [Super], key: "d"): Spawn("rofi -show drun")"#),
"{ron}"
);
assert!(
ron.contains(r#"(modifiers: [Super], key: "q"): Close"#),
"{ron}"
);
}
#[test]
fn a_bind_expands_variables_like_the_mainmod_idiom_everyone_uses() {
// Practically every hyprland.conf opens with `$mainMod = SUPER`.
let r = resolved("$mainMod = SUPER\nbind = $mainMod, D, exec, rofi -show drun\n");
let WriteKind::Verbatim(ron) = &r.writes.last().unwrap().kind else {
panic!("expected verbatim RON");
};
assert!(ron.contains("modifiers: [Super]"), "{ron}");
}
#[test]
fn arithmetic_is_not_applied_to_a_command() {
// `eval_arith` would happily rewrite the `-` in a command line.
let r = resolved("bind = SUPER, V, exec, pactl set-sink-volume @DEFAULT_SINK@ -5%\n");
let WriteKind::Verbatim(ron) = &r.writes.last().unwrap().kind else {
panic!("expected verbatim RON");
};
assert!(ron.contains("@DEFAULT_SINK@ -5%"), "{ron}");
}
#[test]
fn binding_the_same_combination_twice_is_an_error_not_a_silent_overwrite() {
let d = errors("bind = SUPER, D, exec, rofi -show drun\nbind = SUPER, D, killactive\n");
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("already bound"), "{}", d[0].message);
assert!(
d[0].help.as_ref().unwrap().contains("line 1"),
"{:?}",
d[0].help
);
}
#[test]
fn no_binds_means_the_shortcuts_file_is_left_alone() {
// Writing an empty map would wipe shortcuts set through COSMIC's UI for
// anyone whose cosmic.conf simply does not mention keybindings.
let r = resolved("general {\n gaps_in = 4\n}\n");
assert!(r
.writes
.iter()
.all(|w| w.target.component != "com.system76.CosmicSettings.Shortcuts"));
}
#[test]
fn a_bad_bind_is_reported_with_the_rest_of_the_file() {
let d = errors("bind = SUPER, X, frobnicate\ngeneral {\n gaps_inn = 8\n}\n");
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
}
fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind {
&r.writes
.iter()
.find(|w| w.target.component == component && w.target.key == key)
.unwrap_or_else(|| panic!("no write for {component}/{key}"))
.kind
}
/// The spec's highest-value property: two conf keys writing into one
/// composite value must fold into a single write carrying both fields.
#[test]
fn gaps_in_and_gaps_out_fold_into_one_write() {
let r = resolved("general {\n gaps_in = 3\n gaps_out = 8\n}\n");
let gap_writes: Vec<_> = r.writes.iter().filter(|w| w.target.key == "gaps").collect();
// One per theme builder — Dark and Light — and no more.
assert_eq!(gap_writes.len(), 2, "expected one folded write per builder");
for w in gap_writes {
match &w.kind {
WriteKind::Projected(fields) => {
assert_eq!(fields.len(), 2, "both halves must survive folding");
assert_eq!(fields[&vec!["1".to_string()]], Value::U32(3), "inner");
assert_eq!(fields[&vec!["0".to_string()]], Value::U32(8), "outer");
}
other => panic!("expected Projected, got {other:?}"),
}
}
}
#[test]
fn gaps_land_on_the_verified_tuple_indices() {
// (outer, inner) per theme.rs:895 — swapping these silently ruins the
// user's layout, so assert the concrete indices.
let r = resolved("general {\n gaps_in = 3\n gaps_out = 8\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
WriteKind::Projected(f) => {
assert_eq!(f[&vec!["0".to_string()]], Value::U32(8));
assert_eq!(f[&vec!["1".to_string()]], Value::U32(3));
}
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn direct_keys_produce_whole_writes() {
let r = resolved("general {\n autotile = true\n}\n");
assert_eq!(
find(&r, "com.system76.CosmicComp", "autotile"),
&WriteKind::Whole(Value::Bool(true))
);
}
#[test]
fn variables_expand() {
let r = resolved("$gap = 5\ngeneral {\n gaps_in = $gap\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
WriteKind::Projected(f) => assert_eq!(f[&vec!["1".to_string()]], Value::U32(5)),
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn arithmetic_on_variables_works() {
let r = resolved("$gap = 4\ngeneral {\n gaps_out = $gap * 2\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
WriteKind::Projected(f) => assert_eq!(f[&vec!["0".to_string()]], Value::U32(8)),
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn longer_variable_names_win() {
// `$gap` must not eat the prefix of `$gaps`.
let r = resolved("$gap = 1\n$gaps = 7\ngeneral {\n gaps_in = $gaps\n}\n");
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "gaps") {
WriteKind::Projected(f) => assert_eq!(f[&vec!["1".to_string()]], Value::U32(7)),
other => panic!("expected Projected, got {other:?}"),
}
}
#[test]
fn rgb_colors_parse_and_drop_alpha() {
// `accent` is Option<Srgb> — theme.rs:856 — so alpha must not appear.
let r = resolved("theme {\n accent = rgb(6b9fed)\n}\n");
assert_eq!(
find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent"),
&WriteKind::Whole(Value::Rgb(0x6b, 0x9f, 0xed))
);
}
#[test]
fn theme_mode_maps_to_is_dark() {
let r = resolved("theme {\n mode = dark\n}\n");
assert_eq!(
find(&r, "com.system76.CosmicTheme.Mode", "is_dark"),
&WriteKind::Whole(Value::Bool(true))
);
let r = resolved("theme {\n mode = light\n}\n");
assert_eq!(
find(&r, "com.system76.CosmicTheme.Mode", "is_dark"),
&WriteKind::Whole(Value::Bool(false))
);
}
#[test]
fn invalid_theme_mode_is_rejected() {
let d = errors("theme {\n mode = purple\n}\n");
assert!(
d[0].message.contains("`dark` or `light`"),
"{}",
d[0].message
);
}
/// `#` always begins a comment, so a bare hex colour is stripped before it
/// reaches the value parser. This must fail loudly rather than silently
/// yield an empty value.
#[test]
fn bare_hex_color_is_rejected_because_hash_is_a_comment() {
let d = errors("theme {\n accent = #6b9fed\n}\n");
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("colour"), "{}", d[0].message);
}
#[test]
fn rgba_keeps_alpha() {
// `bg_color` is Option<Srgba> — theme.rs:852 — so alpha survives.
let r = resolved("theme {\n bg_color = rgba(6b9fed80)\n}\n");
assert_eq!(
find(&r, "com.system76.CosmicTheme.Dark.Builder", "bg_color"),
&WriteKind::Whole(Value::Rgba(0x6b, 0x9f, 0xed, 0x80))
);
}
#[test]
fn a_multibyte_character_in_a_colour_is_an_error_not_a_panic() {
// `hex.len()` and the slicing that follows it are both in bytes, so
// "€abc" is six bytes and would have been sliced mid-character.
for raw in ["rgb(€abc)", "rgba(ff€€ff00)", "rgb(αβγ)"] {
let d = errors(&format!("theme {{\n accent = {raw}\n}}\n"));
assert_eq!(d.len(), 1, "{raw}");
assert!(d[0].message.contains("colour"), "{}", d[0].message);
}
}
#[test]
fn unknown_key_suggests_the_near_miss() {
let d = errors("general {\n gaps_inn = 8\n}\n");
assert_eq!(d.len(), 1);
assert!(d[0].message.contains("unknown key"), "{}", d[0].message);
assert_eq!(
d[0].help.as_deref(),
Some("did you mean `general.gaps_in`?")
);
assert_eq!(d[0].span.line, 2);
}
#[test]
fn type_errors_are_reported_against_the_value() {
let d = errors("general {\n gaps_in = purple\n}\n");
assert_eq!(d.len(), 1);
assert!(
d[0].message.contains("non-negative integer"),
"{}",
d[0].message
);
}
/// The alias has to land on the same cosmic-config key as COSMIC's own
/// spelling, or the two would be separate settings that merely look alike.
#[test]
fn follow_mouse_is_the_same_write_as_focus_follows_cursor() {
let hypr = resolved("input {\n follow_mouse = 1\n}\n");
let cosmic = resolved("general {\n focus_follows_cursor = true\n}\n");
assert_eq!(hypr.writes, cosmic.writes);
assert_eq!(hypr.writes.len(), 1);
assert_eq!(hypr.writes[0].target.key, "focus_follows_cursor");
assert_eq!(hypr.writes[0].kind, WriteKind::Whole(Value::Bool(true)));
}
#[test]
fn follow_mouse_zero_is_click_to_focus() {
let r = resolved("input {\n follow_mouse = 0\n}\n");
assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::Bool(false)));
}
/// Hyprland accepts 2 and 3, which detach pointer focus from keyboard
/// focus. cosmic-comp has one focus and cannot, so the values are refused.
/// Rounding them to 1 would hand click-to-focus users the opposite of what
/// they asked for and never say so.
#[test]
fn follow_mouse_rejects_the_modes_cosmic_cannot_express() {
for v in ["2", "3"] {
let d = errors(&format!("input {{\n follow_mouse = {v}\n}}\n"));
assert_eq!(d.len(), 1, "{v}: {d:?}");
assert!(
d[0].message.contains("no COSMIC equivalent"),
"{v}: {}",
d[0].message
);
assert!(
d[0].help.as_deref().unwrap_or_default().contains("Use `1`"),
"{v}: help should say what to write instead, got {:?}",
d[0].help
);
}
}
/// It is an integer setting in Hyprland, so `true` is not one of its
/// spellings even though the value it resolves to is a boolean.
#[test]
fn follow_mouse_does_not_quietly_accept_boolean_spellings() {
let d = errors("input {\n follow_mouse = true\n}\n");
assert_eq!(d.len(), 1);
assert!(
d[0].message.contains("`0`") && d[0].message.contains("`1`"),
"{}",
d[0].message
);
}
#[test]
fn follow_mouse_delay_shares_the_delay_key_and_its_range() {
let r = resolved("input {\n follow_mouse_delay = 400\n}\n");
assert_eq!(r.writes[0].target.key, "focus_follows_cursor_delay");
assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::U32(400)));
let d = errors("input {\n follow_mouse_delay = 9001\n}\n");
assert!(
d[0].message.contains("outside the allowed range"),
"{}",
d[0].message
);
}
/// Both spellings in one file is not an error: the file's own rule is that
/// the last assignment wins, and these are two names for one target.
#[test]
fn the_last_spelling_in_the_file_wins() {
let r = resolved(
"general {\n focus_follows_cursor = true\n}\ninput {\n follow_mouse = 0\n}\n",
);
assert_eq!(r.writes.len(), 1, "one target, not two: {:?}", r.writes);
assert_eq!(r.writes[0].kind, WriteKind::Whole(Value::Bool(false)));
}
#[test]
fn out_of_range_values_are_rejected() {
let d = errors("general {\n gaps_in = 9999\n}\n");
assert!(
d[0].message.contains("outside the allowed range"),
"{}",
d[0].message
);
}
#[test]
fn all_diagnostics_are_reported_not_just_the_first() {
let d = errors("general {\n gaps_inn = 8\n autotile = maybe\n}\n");
assert_eq!(d.len(), 2, "expected both errors, got {d:?}");
}
/// Transactionality: any error means zero writes escape.
#[test]
fn a_single_error_produces_no_writes() {
let ast = parse("general {\n autotile = true\n gaps_in = nope\n}\n").unwrap();
assert!(resolve(&ast).is_err(), "must not partially apply");
}
}
+501
View File
@@ -0,0 +1,501 @@
//! Declarative registry mapping `cosmic.conf` keys onto cosmic-config targets.
//!
//! This is data, not code: adding a knob is a table row. Every fact encoded here
//! was verified against a checkout rather than assumed — see the spec's
//! "Verified findings" table for file:line evidence.
/// Scalar types a conf value can carry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ty {
Bool,
U32,
F32,
Str,
/// `rgb(rrggbb)` -> `Option<Srgb>` (no alpha). Bare `#rrggbb` is not
/// accepted: `#` begins a comment.
Rgb,
/// `rgb(rrggbb)`/`rgba(rrggbbaa)` -> `Option<Srgba>` (with alpha).
Rgba,
/// `dark`/`light` -> the `is_dark` boolean.
Mode,
/// Hyprland's `input:follow_mouse`, `0`-`3` -> `focus_follows_cursor`.
///
/// COSMIC's setting is a plain boolean, so only `0` and `1` have a meaning
/// here. Hyprland's `2` and `3` split pointer focus from keyboard focus,
/// which cosmic-comp cannot express -- it has one focus and moves it or
/// does not. They are rejected rather than rounded to `1`: silently
/// granting click-to-focus to someone who asked for the opposite is worse
/// than telling them the setting does not exist here.
FollowMouse,
}
/// Where a conf key's value lands in the cosmic-config tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
/// The conf key owns the entire cosmic-config value.
Direct {
component: &'static str,
version: u8,
key: &'static str,
},
/// The conf key owns one field within a composite value. Requires
/// read-modify-write, and multiple conf keys may share one target.
Projected {
component: &'static str,
version: u8,
key: &'static str,
path: &'static [&'static str],
},
}
impl Target {
pub fn component(&self) -> &'static str {
match self {
Target::Direct { component, .. } | Target::Projected { component, .. } => component,
}
}
pub fn version(&self) -> u8 {
match self {
Target::Direct { version, .. } | Target::Projected { version, .. } => *version,
}
}
pub fn key(&self) -> &'static str {
match self {
Target::Direct { key, .. } | Target::Projected { key, .. } => key,
}
}
}
/// Inclusive numeric bounds, checked during `resolve`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Range {
pub min: f64,
pub max: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct Entry {
/// Dotted path as written in the file, e.g. `general.gaps_in`.
pub conf: &'static str,
/// One conf key may fan out to several components — Dark and Light theme
/// builders are separate cosmic-config components holding the same field.
pub targets: &'static [Target],
pub ty: Ty,
pub validate: Option<Range>,
/// Generates `cosmic.conf.default`, so the reference file cannot drift.
pub doc: &'static str,
}
const DARK_BUILDER: &str = "com.system76.CosmicTheme.Dark.Builder";
const LIGHT_BUILDER: &str = "com.system76.CosmicTheme.Light.Builder";
const COMP: &str = "com.system76.CosmicComp";
const TK: &str = "com.system76.CosmicTk";
const THEME_MODE: &str = "com.system76.CosmicTheme.Mode";
/// `ThemeBuilder.gaps` is `(u32, u32)` ordered **(outer, inner)** —
/// `cosmic-theme/src/model/theme.rs:895`. Index 0 is the outer gap.
const GAPS_OUTER_IDX: &str = "0";
const GAPS_INNER_IDX: &str = "1";
macro_rules! both_themes {
($key:literal, $path:expr) => {
&[
Target::Projected {
component: DARK_BUILDER,
version: 1,
key: $key,
path: $path,
},
Target::Projected {
component: LIGHT_BUILDER,
version: 1,
key: $key,
path: $path,
},
]
};
}
/// Whole-value fan-out across both theme builders. An empty projection path
/// would be a lie: these fields are `Option<..>` written in full.
macro_rules! both_themes_direct {
($key:literal) => {
&[
Target::Direct {
component: DARK_BUILDER,
version: 1,
key: $key,
},
Target::Direct {
component: LIGHT_BUILDER,
version: 1,
key: $key,
},
]
};
}
pub const REGISTRY: &[Entry] = &[
// ---- general ---------------------------------------------------------
Entry {
conf: "general.gaps_in",
targets: both_themes!("gaps", &[GAPS_INNER_IDX]),
ty: Ty::U32,
validate: Some(Range {
min: 0.0,
max: 128.0,
}),
doc: "Gap between adjacent tiled windows, in px",
},
Entry {
conf: "general.gaps_out",
targets: both_themes!("gaps", &[GAPS_OUTER_IDX]),
ty: Ty::U32,
validate: Some(Range {
min: 0.0,
max: 256.0,
}),
doc: "Gap between tiled windows and the screen edge, in px",
},
Entry {
conf: "general.autotile",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "autotile",
}],
ty: Ty::Bool,
validate: None,
doc: "Automatically tile new windows",
},
Entry {
conf: "general.preserve_split",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "preserve_split",
}],
ty: Ty::Bool,
validate: None,
doc: "Open new windows alongside the focused one instead of splitting it",
},
Entry {
conf: "general.active_hint",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "active_hint",
}],
ty: Ty::Bool,
validate: None,
doc: "Draw a hint around the focused window",
},
Entry {
conf: "general.focus_follows_cursor",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "focus_follows_cursor",
}],
ty: Ty::Bool,
validate: None,
doc: "Move keyboard focus when the cursor enters a window",
},
Entry {
conf: "general.focus_follows_cursor_delay",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "focus_follows_cursor_delay",
}],
ty: Ty::U32,
validate: Some(Range {
min: 0.0,
max: 5000.0,
}),
doc: "Delay in ms before focus follows the cursor",
},
Entry {
conf: "general.cursor_follows_focus",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "cursor_follows_focus",
}],
ty: Ty::Bool,
validate: None,
doc: "Warp the cursor to the window that gains keyboard focus",
},
Entry {
conf: "general.edge_snap_threshold",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "edge_snap_threshold",
}],
ty: Ty::U32,
validate: Some(Range {
min: 0.0,
max: 256.0,
}),
doc: "Distance in px at which windows snap to output edges",
},
// ---- decoration ------------------------------------------------------
Entry {
conf: "decoration.rounding",
targets: both_themes!("corner_radii", &["radius_m"]),
ty: Ty::F32,
validate: Some(Range {
min: 0.0,
max: 64.0,
}),
doc: "Window corner radius in px (maps to the theme's radius_m)",
},
// ---- input -----------------------------------------------------------
//
// Aliases, not new settings: both of these land on the same cosmic-config
// keys as `general.focus_follows_cursor` and its delay, which stay for
// anyone who prefers COSMIC's own naming. They exist because Hyprland puts
// this in `input` under a different name, and accepting the Hyprland
// spelling is the point of the fork.
//
// Two spellings writing one target is safe here only because the last
// assignment wins: setting both in one file is not an error, it just means
// whichever comes last is what the compositor gets. That is the same rule
// the rest of the file follows, so it needs no special handling.
Entry {
conf: "input.follow_mouse",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "focus_follows_cursor",
}],
ty: Ty::FollowMouse,
// No `Range`: `check_range` only inspects numeric values and this
// resolves to a bool, so a range here would be silently ignored. The
// accepted values are enforced by `Ty::FollowMouse` itself.
validate: None,
doc: "1 for focus follows mouse, 0 for click to focus",
},
Entry {
conf: "input.follow_mouse_delay",
targets: &[Target::Direct {
component: COMP,
version: 1,
key: "focus_follows_cursor_delay",
}],
ty: Ty::U32,
validate: Some(Range {
min: 0.0,
max: 5000.0,
}),
doc: "Delay in ms before focus follows the mouse",
},
// ---- theme -----------------------------------------------------------
Entry {
conf: "theme.mode",
targets: &[Target::Direct {
component: THEME_MODE,
version: 1,
key: "is_dark",
}],
ty: Ty::Mode,
validate: None,
doc: "`dark` or `light`",
},
Entry {
conf: "theme.accent",
targets: both_themes_direct!("accent"),
ty: Ty::Rgb,
validate: None,
doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)",
},
Entry {
conf: "theme.bg_color",
targets: both_themes_direct!("bg_color"),
ty: Ty::Rgba,
validate: None,
doc: "Background base colour",
},
Entry {
conf: "theme.icon_theme",
targets: &[Target::Direct {
component: TK,
version: 1,
key: "icon_theme",
}],
ty: Ty::Str,
validate: None,
doc: "Icon theme name, e.g. Tela-circle-dracula",
},
];
/// Exact lookup by dotted conf path.
pub fn lookup(conf: &str) -> Option<&'static Entry> {
REGISTRY.iter().find(|e| e.conf == conf)
}
/// Nearest known key by edit distance, for "did you mean" diagnostics.
/// Only suggests when the candidate is close enough to be plausible.
pub fn suggest(conf: &str) -> Option<&'static str> {
let budget = match conf.len() {
0..=4 => 1,
5..=8 => 2,
_ => 3,
};
REGISTRY
.iter()
.map(|e| (edit_distance(conf, e.conf), e.conf))
.filter(|(d, _)| *d <= budget)
.min_by_key(|(d, _)| *d)
.map(|(_, c)| c)
}
/// Levenshtein distance, two-row variant.
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gaps_use_the_verified_tuple_order() {
// ThemeBuilder.gaps is (outer, inner) — theme.rs:895. Getting this
// backwards silently swaps the user's gaps, so pin it.
let inner = lookup("general.gaps_in").unwrap();
let outer = lookup("general.gaps_out").unwrap();
for t in inner.targets {
match t {
Target::Projected { path, .. } => assert_eq!(*path, &["1"]),
other => panic!("gaps_in should project, got {other:?}"),
}
}
for t in outer.targets {
match t {
Target::Projected { path, .. } => assert_eq!(*path, &["0"]),
other => panic!("gaps_out should project, got {other:?}"),
}
}
}
#[test]
fn theme_keys_fan_out_to_dark_and_light() {
let e = lookup("general.gaps_in").unwrap();
let comps: Vec<_> = e.targets.iter().map(|t| t.component()).collect();
assert!(comps.contains(&"com.system76.CosmicTheme.Dark.Builder"));
assert!(comps.contains(&"com.system76.CosmicTheme.Light.Builder"));
assert_eq!(comps.len(), 2);
}
#[test]
fn comp_keys_do_not_fan_out() {
let e = lookup("general.autotile").unwrap();
assert_eq!(e.targets.len(), 1);
assert_eq!(e.targets[0].component(), "com.system76.CosmicComp");
}
#[test]
fn preserve_split_targets_the_forks_own_comp_key() {
// Unlike every other `general` key this one does not exist upstream --
// it is a field this fork adds to `CosmicCompConfig`. If a rebase ever
// drops that field the compositor silently ignores the key, so pin the
// exact target here rather than trusting the generic registry checks.
let e = lookup("general.preserve_split").unwrap();
assert_eq!(e.targets.len(), 1);
assert_eq!(e.targets[0].component(), "com.system76.CosmicComp");
assert!(matches!(e.ty, Ty::Bool));
}
/// `input.*` is an alias layer, so what matters is that it points at the
/// same place COSMIC's own naming does. If a rebase renames either target
/// key, one spelling would keep working and the other would go quietly
/// dead; pinning them together here makes that a test failure instead.
#[test]
fn the_input_section_aliases_the_general_focus_keys() {
for (hypr, cosmic) in [
("input.follow_mouse", "general.focus_follows_cursor"),
(
"input.follow_mouse_delay",
"general.focus_follows_cursor_delay",
),
] {
let a = lookup(hypr).unwrap();
let b = lookup(cosmic).unwrap();
assert_eq!(a.targets, b.targets, "{hypr} and {cosmic} have drifted");
}
}
/// The alias is not a plain bool: Hyprland writes it as a number, and
/// `Ty::FollowMouse` is what turns the accepted numbers into one.
#[test]
fn follow_mouse_uses_the_hyprland_numeric_type() {
assert!(matches!(
lookup("input.follow_mouse").unwrap().ty,
Ty::FollowMouse
));
assert!(matches!(
lookup("general.focus_follows_cursor").unwrap().ty,
Ty::Bool
));
}
#[test]
fn every_entry_has_at_least_one_target() {
for e in REGISTRY {
assert!(!e.targets.is_empty(), "`{}` has no targets", e.conf);
}
}
#[test]
fn conf_paths_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for e in REGISTRY {
assert!(seen.insert(e.conf), "duplicate registry entry `{}`", e.conf);
}
}
#[test]
fn every_entry_is_documented() {
// `doc` generates cosmic.conf.default; an empty one would ship a blank
// reference line.
for e in REGISTRY {
assert!(!e.doc.trim().is_empty(), "`{}` has no doc", e.conf);
}
}
#[test]
fn suggests_near_misses() {
assert_eq!(suggest("general.gaps_inn"), Some("general.gaps_in"));
assert_eq!(suggest("general.autotil"), Some("general.autotile"));
}
#[test]
fn does_not_suggest_nonsense() {
assert_eq!(suggest("completely.unrelated.nonsense.key"), None);
}
}
+737
View File
@@ -0,0 +1,737 @@
//! Filesystem watch: re-apply `cosmic.conf` on every edit.
//!
//! Two problems make this more than "call `notify` and re-run `main`'s
//! pipeline":
//!
//! 1. **`source` fans out the watch set.** `parser::Item::Source` lets a
//! config pull in other files (`parser.rs:66`), but `resolve` treats
//! `Source` as inert (`resolve.rs:87`) — nothing upstream actually expands
//! it yet, even though `resolve.rs:66` already assumes an "include
//! expansion" pass ran first. This module is that pass: `merge_text`
//! textually splices a sourced file's contents in place of its `source`
//! line, the same way Hyprland treats `source` as literal inclusion. Doing
//! it as text rather than AST-splicing means the merged string is one
//! coherent document, so `Span`s (which are just line/col, with no file
//! identity — `parser.rs:24`) stay correct for `render_diagnostic`
//! regardless of which physical file a line came from. It also means the
//! watch set has to be recomputed after every successful compile, since
//! editing a `source` line can add or remove files from it.
//!
//! 2. **A bad edit must not kill the daemon or half-apply.** `Emitter::plan`
//! already keeps `apply` transactional (`emit.rs:11-14`); this module's
//! job is to keep that guarantee across an unbounded stream of edits by
//! treating every compile failure as "log it and keep watching" rather
//! than propagating it out of the loop.
//!
//! The event loop itself (`watch`) is intentionally thin. Everything with
//! interesting logic — merging sources, debouncing — is a free function
//! usable without a real inotify watcher, per the module's tests.
use std::collections::HashSet;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use crate::emit::{EmitError, Emitter, Planned};
use crate::parser::{self, Item, ParseError};
use crate::render_diagnostic;
use crate::resolve::{self, Diagnostic};
/// Editors commonly write a save as several syscalls (truncate, write,
/// rename); this is long enough to collapse those into one recompile without
/// making a real edit feel laggy.
const DEBOUNCE: Duration = Duration::from_millis(250);
/// Everything that can go wrong compiling `config` (and whatever it sources)
/// into a plan. Every variant renders a complete, human-readable report —
/// `watch` just prints `Display` and moves on.
#[derive(Debug)]
pub enum CompileError {
/// `config`, or something it `source`s, could not be read.
Read { path: PathBuf, error: io::Error },
/// A `source` chain refers back to a file already being expanded.
/// Splicing it would recurse forever, so this is reported instead.
Cycle { path: PathBuf },
/// A single file failed to parse on its own, before merging — `source`
/// and `error` are both that file's, so the line number is exact.
Parse {
path: PathBuf,
source: String,
error: ParseError,
},
/// The merged document failed to resolve. `source` is the full merged
/// text, so `diagnostics`' spans point at the right physical line no
/// matter which file contributed it.
Resolve {
source: String,
diagnostics: Vec<Diagnostic>,
},
/// Resolved cleanly but could not be turned into file contents.
Emit(Vec<EmitError>),
}
impl fmt::Display for CompileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CompileError::Read { path, error } => {
writeln!(f, "error: cannot read {}: {error}", path.display())
}
CompileError::Cycle { path } => {
writeln!(
f,
"error: `source` cycle detected while expanding {}",
path.display()
)
}
CompileError::Parse {
path,
source,
error,
} => {
write!(
f,
"in {}:\n{}",
path.display(),
render_diagnostic(source, error.span, &error.message, None)
)
}
CompileError::Resolve {
source,
diagnostics,
} => {
let mut out = String::new();
for d in diagnostics {
out.push_str(&render_diagnostic(
source,
d.span,
&d.message,
d.help.as_deref(),
));
out.push('\n');
}
out.push_str(&format!(
"error: {} problem(s) found; nothing was written\n",
diagnostics.len()
));
write!(f, "{out}")
}
CompileError::Emit(errs) => {
let mut out = String::new();
for e in errs {
out.push_str(&format!("error: {e}\n"));
}
out.push_str("error: nothing was written\n");
write!(f, "{out}")
}
}
}
}
impl std::error::Error for CompileError {}
/// A completed compile: what to write, and what to watch.
#[derive(Debug)]
pub struct Compiled {
pub planned: Vec<Planned>,
/// Every file that contributed content, `config` first. This is exactly
/// the set `watch` needs to be subscribed to for the *next* edit to be
/// noticed, and it can change from one compile to the next as `source`
/// lines are added, removed, or edited.
pub sources: Vec<PathBuf>,
}
/// Parse `config` — following `source` directives — resolve, and plan writes
/// against `emitter`, without touching disk.
///
/// Pulled out of `watch` so the compile pipeline is unit-testable without a
/// filesystem watcher: every test in this module drives `compile` directly.
pub fn compile(config: &Path, emitter: &Emitter) -> Result<Compiled, CompileError> {
let mut ancestors = Vec::new();
let mut sources = Vec::new();
let merged = merge_text(config, &mut ancestors, &mut sources)?;
let ast = parser::parse(&merged).map_err(|error| CompileError::Parse {
path: config.to_path_buf(),
source: merged.clone(),
error,
})?;
let resolved = resolve::resolve(&ast).map_err(|diagnostics| CompileError::Resolve {
source: merged,
diagnostics,
})?;
let planned = emitter.plan(&resolved).map_err(CompileError::Emit)?;
Ok(Compiled { planned, sources })
}
/// Read `path`, then replace every `source = <path>` line with the
/// (recursively expanded) text of the sourced file, so the result is one
/// document `parser::parse` can consume in a single pass — see the module
/// doc for why textual splicing rather than AST splicing.
///
/// `ancestors` is the current inclusion chain (for cycle detection);
/// `watched` accumulates every file visited, in the order first seen.
fn merge_text(
path: &Path,
ancestors: &mut Vec<PathBuf>,
watched: &mut Vec<PathBuf>,
) -> Result<String, CompileError> {
let key = path.to_path_buf();
if ancestors.contains(&key) {
return Err(CompileError::Cycle { path: key });
}
let raw = fs::read_to_string(path).map_err(|error| CompileError::Read {
path: key.clone(),
error,
})?;
watched.push(key.clone());
// Parsing here (rather than scanning text for `source =` ourselves) means
// we inherit the grammar's exact rules for comments and whitespace, so
// the line we splice at is always the one the real parser would call a
// `Source` item.
let ast = parser::parse(&raw).map_err(|error| CompileError::Parse {
path: key.clone(),
source: raw.clone(),
error,
})?;
let mut targets = Vec::new();
collect_sources(&ast.items, &mut targets);
if targets.is_empty() {
return Ok(raw);
}
// Splicing changes line counts, so process bottom-up: replacing a later
// line first leaves every earlier line number still valid.
targets.sort_by_key(|t| std::cmp::Reverse(t.0));
ancestors.push(key);
let mut lines: Vec<String> = raw.lines().map(String::from).collect();
for (line_no, raw_path) in targets {
let target_path = resolve_source_path(path, &raw_path);
let included = merge_text(&target_path, ancestors, watched)?;
lines.splice(line_no - 1..line_no, included.lines().map(String::from));
}
ancestors.pop();
let mut out = lines.join("\n");
out.push('\n');
Ok(out)
}
/// Depth-first walk collecting every `source` item's `(line, raw path)`.
/// Sections are recursed into: a `source` nested inside `general { .. }`
/// splices its contents into that section, matching Hyprland's textual
/// `source` semantics rather than only supporting top-level includes.
fn collect_sources(items: &[Item], out: &mut Vec<(usize, String)>) {
for item in items {
match item {
Item::Source { path } => out.push((path.span.line, path.value.clone())),
Item::Section { items, .. } => collect_sources(items, out),
_ => {}
}
}
}
/// Resolve a `source` value the way a shell prompt would: `~/` against
/// `$HOME`, everything else relative to the directory of the file doing the
/// sourcing (not the process's cwd), so a config tree keeps working wherever
/// it is checked out.
fn resolve_source_path(containing_file: &Path, raw: &str) -> PathBuf {
let expanded = if raw == "~" {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(raw))
} else if let Some(rest) = raw.strip_prefix("~/") {
match std::env::var_os("HOME") {
Some(home) => PathBuf::from(home).join(rest),
None => PathBuf::from(raw),
}
} else {
PathBuf::from(raw)
};
if expanded.is_absolute() {
expanded
} else {
containing_file
.parent()
.unwrap_or_else(|| Path::new("."))
.join(expanded)
}
}
/// Block for the first item on `rx`, then keep draining anything that
/// arrives within `window` of the previous one. Returns `None` once the
/// sender side has been dropped and nothing more will ever come.
///
/// This is the whole debounce policy, factored out of `watch`'s loop so it
/// can be tested against a plain channel instead of real filesystem events —
/// editors write a save as several syscalls, and without this a single save
/// would trigger several redundant recompiles.
fn collect_batch<T>(rx: &mpsc::Receiver<T>, window: Duration) -> Option<Vec<T>> {
let first = rx.recv().ok()?;
let mut batch = vec![first];
while let Ok(next) = rx.recv_timeout(window) {
batch.push(next);
}
Some(batch)
}
/// Bring `watcher`'s subscriptions in line with `wanted`, diffing against
/// `current` so files that stopped being sourced are actually unwatched
/// (otherwise the watch set only ever grows).
///
/// Best-effort: a `watch`/`unwatch` failure (e.g. a sourced file that does
/// not exist yet) is not fatal — the next successful compile will retry with
/// whatever the config asks for at that point.
fn sync_watches(
watcher: &mut RecommendedWatcher,
current: &mut HashSet<PathBuf>,
wanted: &[PathBuf],
) {
let wanted: HashSet<PathBuf> = wanted.iter().cloned().collect();
for stale in current.difference(&wanted) {
let _ = watcher.unwatch(stale);
}
for fresh in wanted.difference(current) {
let _ = watcher.watch(fresh, RecursiveMode::NonRecursive);
}
*current = wanted;
}
/// Anything that stops the daemon outright. Deliberately small: a broken
/// `cosmic.conf` is *not* one of these — see the module doc — so this is
/// only the notify plumbing itself failing to start.
#[derive(Debug)]
pub enum WatchError {
Notify(notify::Error),
}
impl fmt::Display for WatchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WatchError::Notify(e) => write!(f, "watch error: {e}"),
}
}
}
impl std::error::Error for WatchError {}
impl From<notify::Error> for WatchError {
fn from(e: notify::Error) -> Self {
WatchError::Notify(e)
}
}
/// Watch `config` — and everything it currently `source`s — reapplying on
/// every change until the watcher itself fails to start or stops delivering
/// events. A malformed edit is reported to stderr and waited past: see the
/// module doc for why that, not propagating the error, is the contract here.
pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> {
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?;
let mut watched: HashSet<PathBuf> = HashSet::new();
// The last diagnostic printed, so an unchanged one is not printed again.
// A single save arrives as several inotify events -- modify, then
// close_write, sometimes a rename when the editor writes atomically -- and
// they do not all land inside one debounce window, so a broken config
// otherwise reports itself three or four times per keystroke-save. Cleared
// on every successful compile, so the same error reappearing after a good
// one is still news and still printed.
let mut last_error: Option<String> = None;
// Compile once up front: the desktop should reflect the config the
// moment the daemon starts, and this also tells us the initial watch
// set. If it fails, fall back to watching just `config` — that is the
// one file guaranteed to exist, and a later successful compile will
// widen the watch set to whatever it actually sources.
match compile(config, emitter) {
Ok(compiled) => {
if let Err(e) = emitter.apply(&compiled.planned) {
eprintln!("{}", CompileError::Emit(vec![e]));
}
sync_watches(&mut watcher, &mut watched, &compiled.sources);
}
Err(e) => {
let text = e.to_string();
eprintln!("{text}");
last_error = Some(text);
sync_watches(
&mut watcher,
&mut watched,
std::slice::from_ref(&config.to_path_buf()),
);
}
}
loop {
let Some(batch) = collect_batch(&rx, DEBOUNCE) else {
// The sender was dropped, which only happens if `watcher` itself
// was torn down — nothing more will ever arrive.
return Ok(());
};
for event in &batch {
if let Err(e) = event {
eprintln!("watch error: {e}");
}
}
match compile(config, emitter) {
Ok(compiled) => {
last_error = None;
if let Err(e) = emitter.apply(&compiled.planned) {
eprintln!("{}", CompileError::Emit(vec![e]));
}
sync_watches(&mut watcher, &mut watched, &compiled.sources);
}
Err(e) => {
// Leave `watched` alone: the fix for a bad edit might land in
// an already-sourced file, and dropping back to watching
// only `config` would miss that.
let text = e.to_string();
if last_error.as_deref() != Some(text.as_str()) {
eprintln!("{text}");
last_error = Some(text);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(dir: &Path, name: &str, contents: &str) -> PathBuf {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, contents).unwrap();
path
}
// ---- compile ----------------------------------------------------
#[test]
fn compile_with_no_source_directives_watches_just_the_config() {
let conf_dir = TempDir::new().unwrap();
let root_dir = TempDir::new().unwrap();
let config = write(
conf_dir.path(),
"cosmic.conf",
"general {\n autotile = true\n}\n",
);
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
assert_eq!(compiled.sources, vec![config]);
assert_eq!(compiled.planned.len(), 1);
}
#[test]
fn compile_follows_a_source_directive_and_lists_it_as_a_watch_target() {
let conf_dir = TempDir::new().unwrap();
let root_dir = TempDir::new().unwrap();
let included = write(
conf_dir.path(),
"extra.conf",
"general {\n autotile = true\n}\n",
);
let config = write(conf_dir.path(), "cosmic.conf", "source = extra.conf\n");
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
assert_eq!(compiled.sources, vec![config, included]);
assert_eq!(compiled.planned.len(), 1);
}
#[test]
fn compile_expands_a_source_nested_inside_a_section() {
// The sourced file's contents become part of the enclosing section,
// the same way Hyprland's `source` is a literal text substitution.
let conf_dir = TempDir::new().unwrap();
let root_dir = TempDir::new().unwrap();
write(conf_dir.path(), "gaps.conf", "gaps_in = 5\ngaps_out = 10\n");
let config = write(
conf_dir.path(),
"cosmic.conf",
"general {\n source = gaps.conf\n autotile = true\n}\n",
);
let planned = compile(&config, &Emitter::with_root(root_dir.path()))
.unwrap()
.planned;
let gaps = planned
.iter()
.find(|p| p.path.ends_with("gaps"))
.expect("gaps planned");
assert_eq!(gaps.contents, "(10, 5)");
}
#[test]
fn compile_resolves_relative_sources_against_the_including_files_directory() {
// The including file lives in a subdirectory; `nested.conf` must be
// found relative to it, not relative to the process's cwd.
let conf_dir = TempDir::new().unwrap();
let sub = conf_dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
write(&sub, "nested.conf", "general {\n autotile = true\n}\n");
let config = write(&sub, "cosmic.conf", "source = nested.conf\n");
let root_dir = TempDir::new().unwrap();
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
assert_eq!(compiled.sources.len(), 2);
}
#[test]
fn compile_expands_tilde_against_home() {
let home = TempDir::new().unwrap();
write(
home.path(),
"shared.conf",
"general {\n autotile = true\n}\n",
);
let conf_dir = TempDir::new().unwrap();
let config = write(conf_dir.path(), "cosmic.conf", "source = ~/shared.conf\n");
let prev = std::env::var_os("HOME");
std::env::set_var("HOME", home.path());
let result = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path()));
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
let compiled = result.unwrap();
assert!(compiled.sources.contains(&home.path().join("shared.conf")));
}
#[test]
fn compile_follows_a_chain_of_nested_sources() {
let conf_dir = TempDir::new().unwrap();
write(conf_dir.path(), "c.conf", "autotile = true\n");
write(
conf_dir.path(),
"b.conf",
"general {\n source = c.conf\n}\n",
);
let config = write(conf_dir.path(), "a.conf", "source = b.conf\n");
let compiled =
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap();
assert_eq!(compiled.sources.len(), 3);
assert_eq!(compiled.planned.len(), 1);
}
#[test]
fn compile_detects_a_source_cycle() {
let conf_dir = TempDir::new().unwrap();
let a = conf_dir.path().join("a.conf");
let b = conf_dir.path().join("b.conf");
fs::write(&a, "source = b.conf\n").unwrap();
fs::write(&b, "source = a.conf\n").unwrap();
let err = compile(&a, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
assert!(matches!(err, CompileError::Cycle { .. }), "{err}");
}
#[test]
fn compile_reports_a_missing_source_file_without_panicking() {
let conf_dir = TempDir::new().unwrap();
let config = write(
conf_dir.path(),
"cosmic.conf",
"source = does-not-exist.conf\n",
);
let err =
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
assert!(matches!(err, CompileError::Read { .. }), "{err}");
}
#[test]
fn compile_surfaces_a_syntax_error_in_a_sourced_file() {
let conf_dir = TempDir::new().unwrap();
write(conf_dir.path(), "broken.conf", "this is not valid\n");
let config = write(conf_dir.path(), "cosmic.conf", "source = broken.conf\n");
let err =
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
match err {
CompileError::Parse { path, .. } => {
assert_eq!(path, conf_dir.path().join("broken.conf"))
}
other => panic!("expected Parse, got {other}"),
}
}
#[test]
fn compile_diagnostic_line_number_points_at_the_merged_document_not_the_fragment() {
// The offending line is line 1 of `bad.conf`, but after splicing it
// sits at line 2 of the merged document — the diagnostic must report
// the merged position so the caret lands on the right physical line.
let conf_dir = TempDir::new().unwrap();
write(conf_dir.path(), "bad.conf", "gaps_inn = 8\n");
let config = write(
conf_dir.path(),
"cosmic.conf",
"general {\n source = bad.conf\n}\n",
);
let err =
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
match err {
CompileError::Resolve { diagnostics, .. } => {
assert_eq!(diagnostics[0].span.line, 2);
}
other => panic!("expected Resolve, got {other}"),
}
}
#[test]
fn compile_surfaces_resolve_diagnostics_for_an_unknown_key() {
let conf_dir = TempDir::new().unwrap();
let config = write(
conf_dir.path(),
"cosmic.conf",
"general {\n gaps_inn = 8\n}\n",
);
let err =
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
assert!(matches!(err, CompileError::Resolve { .. }), "{err}");
assert!(err.to_string().contains("unknown key"), "{err}");
}
/// Mirrors `emit.rs`'s `plan_does_not_write`: `compile` only plans, so it
/// must leave the cosmic-config tree untouched.
#[test]
fn compile_does_not_write_to_the_config_root() {
let conf_dir = TempDir::new().unwrap();
let config = write(
conf_dir.path(),
"cosmic.conf",
"general {\n autotile = true\n}\n",
);
let root_dir = TempDir::new().unwrap();
let _ = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
assert!(
fs::read_dir(root_dir.path()).unwrap().next().is_none(),
"compile must leave the tree untouched"
);
}
// ---- resolve_source_path -----------------------------------------
#[test]
fn resolve_source_path_is_relative_to_the_including_file_not_the_cwd() {
let including = Path::new("/somewhere/deep/cosmic.conf");
assert_eq!(
resolve_source_path(including, "extra.conf"),
Path::new("/somewhere/deep/extra.conf")
);
}
#[test]
fn resolve_source_path_leaves_absolute_paths_alone() {
let including = Path::new("/somewhere/deep/cosmic.conf");
assert_eq!(
resolve_source_path(including, "/etc/other.conf"),
Path::new("/etc/other.conf")
);
}
// Tilde expansion against `$HOME` is covered end-to-end by
// `compile_expands_tilde_against_home` below rather than here too:
// `std::env::set_var` mutates process-global state, and the default
// test runner is multi-threaded, so two tests racing to set `HOME`
// would be a real source of flakiness rather than a hypothetical one.
// ---- collect_batch (debounce) -------------------------------------
//
// These exercise the debounce policy directly against a plain channel,
// with no filesystem or notify involvement at all, so they are fast and
// cannot flake on OS-level event timing.
#[test]
fn collect_batch_drains_everything_already_sent_before_it_was_called() {
let (tx, rx) = mpsc::channel();
for i in 0..5 {
tx.send(i).unwrap();
}
let batch = collect_batch(&rx, Duration::from_millis(30)).unwrap();
assert_eq!(batch, vec![0, 1, 2, 3, 4]);
}
#[test]
fn collect_batch_returns_none_once_the_sender_is_dropped() {
let (tx, rx) = mpsc::channel::<i32>();
drop(tx);
assert!(collect_batch(&rx, Duration::from_millis(30)).is_none());
}
#[test]
fn collect_batch_starts_a_fresh_batch_after_the_quiet_window_elapses() {
use std::thread;
let (tx, rx) = mpsc::channel();
let window = Duration::from_millis(20);
tx.send(1).unwrap();
let first = collect_batch(&rx, window).unwrap();
assert_eq!(first, vec![1]);
// Send the second burst from another thread after the window has
// safely elapsed (10x margin), so the main thread's blocking `recv`
// in the next `collect_batch` call has something to wake it up.
thread::spawn(move || {
thread::sleep(window * 10);
tx.send(2).unwrap();
});
let second = collect_batch(&rx, window).unwrap();
assert_eq!(second, vec![2]);
}
// ---- sync_watches ---------------------------------------------------
//
// Exercises the real notify watch/unwatch bookkeeping — but only ever
// registers watches on files that already exist; no event is triggered
// or waited for, so this cannot flake on inotify timing.
#[test]
fn sync_watches_adds_and_then_removes_a_watch() {
let dir = TempDir::new().unwrap();
let a = write(dir.path(), "a.conf", "");
let b = write(dir.path(), "b.conf", "");
let (tx, _rx) = mpsc::channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(tx).unwrap();
let mut current = HashSet::new();
sync_watches(&mut watcher, &mut current, &[a.clone(), b.clone()]);
assert_eq!(current, HashSet::from([a.clone(), b.clone()]));
// Dropping `b` from the wanted set must unwatch it, not just stop
// tracking it, or the watch set would only ever grow.
sync_watches(&mut watcher, &mut current, std::slice::from_ref(&a));
assert_eq!(current, HashSet::from([a]));
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Adversarial checks on theme-archive extraction.
//!
//! These are deliberately independent of `assets.rs`'s own unit tests. Theme
//! tarballs are downloaded from third-party repositories and extracted into the
//! user's home directory, so "a test named `path_traversal_is_rejected` passes"
//! is not sufficient evidence — these assert on the *filesystem* afterwards,
//! proving nothing escaped rather than trusting a returned error.
use std::fs;
use std::path::{Path, PathBuf};
use cosmic_conf::assets::Installer;
use flate2::write::GzEncoder;
use flate2::Compression;
use tempfile::TempDir;
/// Build a `.tar.gz` containing arbitrary entries, including hostile ones a
/// well-behaved archiver would refuse to produce.
fn hostile_tarball(path: &Path, entries: &[(&str, tar::EntryType, &[u8], Option<&str>)]) {
let file = fs::File::create(path).unwrap();
let mut builder = tar::Builder::new(GzEncoder::new(file, Compression::default()));
for (name, kind, data, link_target) in entries {
let mut header = tar::Header::new_gnu();
header.set_entry_type(*kind);
header.set_mode(0o644);
header.set_size(if link_target.is_some() {
0
} else {
data.len() as u64
});
// `append_data`/`set_path` reject `..` and absolute paths, so a hostile
// archive cannot be produced through the safe API. Write the raw name
// bytes into the GNU header directly — this is precisely what a
// malicious archiver does, and the only way to test the guard honestly.
write_raw_name(&mut header, name);
if let Some(target) = link_target {
write_raw_link(&mut header, target);
}
header.set_cksum();
builder.append(&header, *data).unwrap();
}
builder.into_inner().unwrap().finish().unwrap();
}
/// Overwrite the GNU header's `name` field with arbitrary bytes, bypassing the
/// validation `Header::set_path` performs.
fn write_raw_name(header: &mut tar::Header, name: &str) {
let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header");
gnu.name = [0u8; 100];
let bytes = name.as_bytes();
assert!(bytes.len() < 100, "fixture name too long for a GNU header");
gnu.name[..bytes.len()].copy_from_slice(bytes);
}
/// Same, for the `linkname` field.
fn write_raw_link(header: &mut tar::Header, target: &str) {
let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header");
gnu.linkname = [0u8; 100];
let bytes = target.as_bytes();
assert!(bytes.len() < 100, "fixture link target too long");
gnu.linkname[..bytes.len()].copy_from_slice(bytes);
}
/// A theme directory just complete enough for `plan` to consider the archive.
fn theme_with_archive(
entries: &[(&str, tar::EntryType, &[u8], Option<&str>)],
) -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().unwrap();
let theme_dir = tmp.path().join("Configs/.config/hyde/themes/Evil");
let source_dir = tmp.path().join("Source");
fs::create_dir_all(&theme_dir).unwrap();
fs::create_dir_all(&source_dir).unwrap();
fs::write(
theme_dir.join("hypr.theme"),
"general {\n gaps_in = 3\n}\n",
)
.unwrap();
hostile_tarball(&source_dir.join("Gtk_Evil.tar.gz"), entries);
(tmp, theme_dir, source_dir)
}
/// Anything created outside the sandbox root is an escape.
fn assert_nothing_outside(canary: &Path) {
assert!(
!canary.exists(),
"archive extraction escaped its destination and wrote {}",
canary.display()
);
}
#[test]
fn parent_dir_traversal_never_writes_outside_destination() {
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
"../../../../../../tmp/cosmic_conf_escape_canary",
tar::EntryType::Regular,
b"pwned",
None,
)]);
let home = tmp.path().join("home");
let data = home.join(".local/share");
let installer = Installer::with_paths(&data, &home);
let result = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true);
// Whether it is rejected at plan time or apply time, the invariant is the
// same: nothing lands outside the destination.
if let Ok(plan) = result {
let _ = installer.apply(&plan);
}
assert_nothing_outside(Path::new("/tmp/cosmic_conf_escape_canary"));
}
#[test]
fn absolute_path_entry_never_writes_outside_destination() {
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
"/tmp/cosmic_conf_abs_canary",
tar::EntryType::Regular,
b"pwned",
None,
)]);
let home = tmp.path().join("home");
let data = home.join(".local/share");
let installer = Installer::with_paths(&data, &home);
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true) {
let _ = installer.apply(&plan);
}
assert_nothing_outside(Path::new("/tmp/cosmic_conf_abs_canary"));
}
/// The subtle one: neither entry path contains `..`, so a naive check passes.
/// The symlink redirects a later, innocent-looking write outside the tree.
#[test]
fn symlink_indirection_never_writes_outside_destination() {
let (tmp, theme_dir, source_dir) = theme_with_archive(&[
("escape", tar::EntryType::Symlink, b"", Some("/tmp")),
(
"escape/cosmic_conf_symlink_canary",
tar::EntryType::Regular,
b"pwned",
None,
),
]);
let home = tmp.path().join("home");
let data = home.join(".local/share");
let installer = Installer::with_paths(&data, &home);
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true) {
let _ = installer.apply(&plan);
}
assert_nothing_outside(Path::new("/tmp/cosmic_conf_symlink_canary"));
}
/// A benign archive must still install, or the guard is uselessly strict.
#[test]
fn well_formed_archive_still_installs() {
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
"Evil-Theme/index.theme",
tar::EntryType::Regular,
b"[Desktop Entry]\n",
None,
)]);
let home = tmp.path().join("home");
let data = home.join(".local/share");
let installer = Installer::with_paths(&data, &home);
let plan = installer
.plan(&theme_dir, Some(&source_dir), "Evil", None, true)
.expect("a well-formed archive must plan cleanly");
installer.apply(&plan).expect("and must apply");
assert!(
home.join(".themes/Evil-Theme/index.theme").exists(),
"benign archive did not install; guard is too strict"
);
}
+1
Submodule cosmic-monitor added at 70e6cff168
Submodule cosmic-sound-theme added at 7aabe44909
@@ -0,0 +1,334 @@
# HyprCosmic — Design
**Date:** 2026-08-09
**Status:** Approved for implementation
## Goal
Run a HyDE-style desktop on COSMIC's compositor: HyDE themes apply end to end — palette,
wallpaper, gaps, rounding, bar, launcher, notifications — with all configuration driven from a
single commented, version-controllable text file in Hyprland's idiom.
The user reviewed the cost of the full-rice target and chose it explicitly over the cheaper
palette-only option.
### What this actually is
HyprCosmic is **HyDE with cosmic-comp as the compositor**, not COSMIC restyled to look like HyDE.
COSMIC's own shell surface — cosmic-panel applets, the workspace overview, and cosmic-settings'
appearance controls — is replaced, not themed. This framing is the honest description and should
appear in the project README.
## Non-goals
- Bit-compatibility with Hyprland's config parser. Syntax is familiar; an existing
`hyprland.conf` will not work, because COSMIC's key names and concepts differ throughout.
- Preserving cosmic-settings as a working appearance editor. Configuration is one-way: the file
wins, and GUI edits are overwritten on next apply.
- Gradient window borders. COSMIC's `active_hint` is a solid hint with no gradient support, and
adding one is out of scope.
- Matching Hyprland's window-management semantics (dwindle/master layouts). cosmic-comp's BSP
tiler stays.
## Verified findings
Everything below was read from the tree at `/home/dingo/cosmic-epoch`, not assumed.
| Finding | Evidence |
|---|---|
| cosmic-comp is GPL-3.0-only; forking is permitted | `cosmic-comp/src/lib.rs:7` |
| `COSMIC_SESSION_SOCK` is optional — cosmic-comp runs standalone | `cosmic-comp/src/session.rs:76,90` |
| wlr-layer-shell is implemented (foreign bars can render) | `cosmic-comp/src/wayland/handlers/layer_shell.rs` |
| ext-session-lock is implemented | `cosmic-comp/src/wayland/handlers/session_lock.rs` |
| ext-workspace-v1 is implemented, plus a cosmic v2 extension | `cosmic-comp/src/wayland/protocols/workspace/ext.rs` |
| `zwlr_foreign_toplevel_management_v1` is **absent** — only ext-foreign-toplevel-list exists | grep across `cosmic-comp/src/`; `handlers/foreign_toplevel_list.rs` |
| Gaps are real and theme-driven, `(u32, u32)` | `layout/tiling/mod.rs:4305`, `layout/floating/mod.rs:1689` |
| Blur exists but is **client-requested** via `ext-background-effect`, not compositor rule | `handlers/background_effect.rs`, `backend/render/wayland/blur_effect.rs`, `shaders/blur_{downsample,upsample}.frag` |
| Shadow and rounded-corner shaders exist | `backend/render/shaders/{shadow,rounded_rectangle,rounded_outline}.frag` |
| Animation engine exists; durations hardcoded | `src/lib.rs:197,209`; `shell/workspace.rs:75` |
| Window rules cover **tiling exceptions only** | `cosmic-settings-daemon/config/src/window_rules/mod.rs:41` |
| Compositor config surface is ~20 flat fields | `cosmic-comp/cosmic-comp-config/src/lib.rs:71-105` |
| cosmic-config is a filesystem KV store, one file per key, sparse (only changed keys materialise) | `~/.config/cosmic/`, 136 files across ~25 components |
| cosmic-config live-reloads via inotify | `cosmic-comp/src/config/mod.rs:173,219,251` |
| cosmic-panel has **zero** CSS/stylesheet support; renders via iced | grep across `cosmic-panel/` |
| Upstream velocity: 46 commits in 30 days | `git log --since="30 days ago"` in cosmic-comp |
### What a HyDE theme actually contains
Measured from `HyDE-Project/hyde-themes`, branch `Catppuccin-Mocha` (25 files):
| File | Size | Contents |
|---|---|---|
| `hypr.theme` | 1,316 B | gaps 3/8, `rounding 10`, `border_size 2`, gradient borders, `blur {size 6, passes 3}`, GTK/icon theme names |
| `waybar.theme` | 358 B | 7 `@define-color` lines — **not** a stylesheet |
| `rofi.theme` | 320 B | ~7 colour variables |
| `kitty.theme` | 1,536 B | palette |
| GTK + icon tarballs | 4.6 MB | standard themes |
| wallpapers | ~95 MB | the bulk |
The theme is ~3.5 KB of text. The bespoke widget styling belongs to HyDE itself, not to any
individual theme — which is why running HyDE's own bar and launcher is the shortest path to
fidelity.
## Architecture
Three repositories. Upstream components not listed are consumed unmodified.
| Repo | Kind | Purpose |
|---|---|---|
| `hyprcosmic/cosmic-comp` | Fork (GPL-3.0) | Protocol patches, then blur/animation config |
| `hyprcosmic/cosmic-conf` | New (GPL-3.0) | Config compiler + theme importer |
| `hyprcosmic/hyprcosmic` | New meta | Submodule pins, session definition, docs |
Runtime composition:
| Layer | Component | Modified? |
|---|---|---|
| Compositor | `hyprcosmic/cosmic-comp` | Yes — patches A, B, then polish |
| Bar | waybar (upstream, MIT) | No — consumes HyDE config + CSS directly |
| Launcher | rofi (upstream) | No — HyDE `.rasi` works |
| Notifications | swaync (upstream) | No |
| Wallpaper | swww (upstream) | No — matches HyDE; `CosmicBackground` unused |
| Session | forked cosmic-session | Yes — gate `start_component` calls |
| Config | `cosmic-conf` | New |
## Phase 1 — `cosmic-conf`
A Rust binary that compiles one text file into the cosmic-config tree. It is a compiler, not a
daemon owning state: COSMIC components keep reading cosmic-config and keep live-reloading through
their existing `ConfigWatchSource`. Nothing in COSMIC learns about `cosmic.conf`.
### Units
| Unit | Responsibility | Depends on |
|---|---|---|
| `parser` | text → AST with byte spans. Sections, `$variables`, `source=`, `#` comments | — |
| `schema` | Declarative registry: conf key → cosmic-config target + type + validator + doc | — |
| `resolve` | AST + schema → typed values. Variable expansion, type/range checking, diagnostics | `parser`, `schema` |
| `emit` | Typed values → cosmic-config writes | `resolve`, `cosmic-config` |
| `watch` | inotify on the conf file and its includes → re-run pipeline | all |
`parser`, `schema` and `resolve` are pure and touch nothing COSMIC-specific, so the hard logic is
unit-testable without a compositor running. Only `emit` binds to `cosmic-config`, and it is the
only unit Phase 2 modifies when new keys land.
Entry points: `cosmic-conf apply` (one-shot, non-zero exit on error), `cosmic-conf watch`,
`cosmic-conf apply --diff` (show what would be overwritten).
### File format
Hyprland-style syntax, hand-written recursive-descent parser (~500 lines). Chosen over KDL and
TOML because the authoring experience is the product requirement; a better-engineered format that
feels wrong fails the goal.
```
$accent = rgb(6b9fed)
$gap = 8
general {
gaps_in = $gap
gaps_out = $gap * 2
autotile = true
active_hint = true
}
decoration {
rounding = 10
}
theme {
mode = dark
accent = $accent
}
bind = SUPER, Return, spawn, kitty
bind = SUPER, Q, close
source = ~/.config/hyprcosmic/monitors.conf
```
### Schema registry
The mapping is not 1:1. Some conf keys own a whole cosmic-config value; others own one field
inside a composite RON value (`decoration.rounding` targets one radius among six in
`corner_radii`; `gaps_in`/`gaps_out` are two halves of one `(u32, u32)`).
```rust
enum Target {
Direct { component: &'static str, version: u8, key: &'static str },
Projected { component: &'static str, version: u8, key: &'static str,
path: &'static [&'static str] },
}
Entry {
conf: "general.gaps_in",
// Fan-out: Dark and Light are separate cosmic-config components
targets: &[
Projected { component: "com.system76.CosmicTheme.Dark.Builder", version: 1,
key: "gaps", path: &["1"] },
Projected { component: "com.system76.CosmicTheme.Light.Builder", version: 1,
key: "gaps", path: &["1"] },
],
ty: Ty::U32,
validate: Some(range(0..=128)),
doc: "Gap between adjacent tiled windows, in px",
}
```
**Spike-corrected facts** (verified in `vendor/libcosmic`):
- `gaps: (u32, u32)` lives on `ThemeBuilder` (`cosmic-theme/src/model/theme.rs:895`), **not** `CosmicTk`.
Component IDs at `theme.rs:17-26`. Default `(0, 8)`.
- Tuple order is **`(outer, inner)`** — so `gaps_out` is index `0` and `gaps_in` is index `1`.
- Dark and Light Builders are **separate components**, so one conf key fans out to two targets.
`Entry` therefore carries `targets: &[Target]`, not a single target.
- `CosmicTk` (`libcosmic/src/config/mod.rs:14`, ID `com.system76.CosmicTk`) holds
`icon_theme`, `interface_font`, `monospace_font`, `header_size`, `interface_density`,
`show_minimize`, `show_maximize`, `apply_theme_global``icon_theme` is needed by the HyDE
importer, which sets `$ICON_THEME`.
**`emit` writes through the typed `cosmic-config` API, not raw files.** `Config::watch`
(`cosmic-config/src/lib.rs:377`) is a `notify` inotify watch on the config directory that derives
changed keys from file paths, so raw writes would in fact be observed — but `Config::set` gives
correct RON encoding per type, atomic writes via `atomicwrites::AtomicFile` (`lib.rs:513`), and
matches the watcher's `.atomicwrite` temp-file filter (`lib.rs:408`). cosmic-conf therefore
depends on `cosmic-theme`, `cosmic-comp-config` and `cosmic-settings-config` for the concrete
types, which also buys compile-time type checking of the registry.
**Critical correctness property:** projected writes are read-modify-write, and multiple conf keys
can share one target. `emit` MUST group by target key, fold all projections, then write once.
Naïve per-key writes let `gaps_out` clobber `gaps_in`. This is directly unit-testable and is the
highest-value test in the suite.
`doc` generates `cosmic.conf.default`, so the annotated reference file cannot drift from the
schema.
### Phase 1 scope
| Section | Targets | Confidence |
|---|---|---|
| `general` | `CosmicComp`: autotile, active_hint, focus_follows_cursor(+delay), cursor_follows_focus, edge_snap_threshold, cursor_hide_timeout | Verified |
| `workspace` | `CosmicComp/workspaces`: mode, layout, wraparound, action_on_typing | Verified |
| `input` | `CosmicComp`: xkb_config, input_default, input_touchpad | Verified |
| `bind` | `CosmicSettings.Shortcuts/custom`, incl. `Spawn(String)` | Verified |
| `windowrule` | `WindowRules`: tiling exceptions only | Verified, deliberately thin |
| `theme` | `CosmicTheme.Mode/is_dark`, `.Builder/{palette,corner_radii,spacing}` | **Unverified** |
| `decoration` | `corner_radii`, `gaps` | **Unverified** |
### Spikes (must complete before schema work)
1. **Fetch libcosmic and enumerate `cosmic-theme` and `CosmicTk`.** `gaps` was inferred from its
use site (`theme.cosmic().gaps`); the struct has not been read. If `gaps` is derived rather
than stored, that row moves to Phase 2 and needs a compositor patch.
2. **Determine whether direct RON file writes trigger `ConfigWatchSource`,** or whether `emit`
must go through the typed `cosmic-config` API. Decides `emit`'s implementation.
### Error handling
The pipeline is transactional: `resolve` fully validates before `emit` writes anything. A
malformed file leaves the desktop untouched rather than half-applied. Diagnostics report against
source text with spans:
```
error: unknown key `gaps_inn` in section `general`
--> cosmic.conf:7:5
|
7 | gaps_inn = 8
| ^^^^^^^^ did you mean `gaps_in`?
```
## Phase 2 — cosmic-comp patches
Ordered by ascending risk. Each is independently shippable. Patches A and B are additive new
files that never touch `shell/layout/tiling/mod.rs` — a 235 KB file that is the most painful
thing in the tree to carry patches against.
### Patch A — `zwlr_foreign_toplevel_management_v1`
New protocol handler alongside `toplevel_info.rs` / `toplevel_management.rs`, which already hold
the required state. Unlocks waybar's `wlr/taskbar`. Plausibly upstreamable. **Rebase risk: low.**
### Patch B — Hyprland-compatible IPC socket
Implement a subset of Hyprland's IPC at
`$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock` (request/response) and `.socket2.sock`
(event stream).
- Requests: `workspaces`, `activeworkspace`, `activewindow`, `clients`, `monitors`
- Events: `workspace>>`, `activewindow>>`, `openwindow>>`, `closewindow>>`
HyDE's `hyprland/workspaces` and `hyprland/window` waybar modules then work unmodified, because
waybar cannot tell the difference. Also delivers the `hyprctl`-style IPC from the original
wishlist. New file, no entanglement with the layout engine. **Rebase risk: low.**
### Polish patches
| Patch | Where | Effort | Rebase risk |
|---|---|---|---|
| Animation curves + durations | `shell/`, config struct | Medium | Low — replaces consts with config lookups |
| Opacity + shadow config | `backend/render/`, `shadow.frag` | Medium | Low — shader exists, needs uniforms |
| Per-monitor/workspace gaps | both layout modules | Low | Low |
| Compositor-driven blur rules | `backend/render/wayland/blur_effect.rs` | High | Medium — inverts client-request model |
| Real window rules | `shell/layout/tiling/mod.rs` | High | **High** — do last |
## Phase 3 — Session and theme importer
### Session
Fork cosmic-session and gate the hardcoded `start_component` calls (cosmic-panel,
cosmic-launcher, cosmic-app-library, cosmic-osd, cosmic-workspaces) behind config. Forking is
preferred over skipping cosmic-session entirely, because cosmic-session also propagates the
compositor environment to systemd/D-Bus and pulls up `graphical-session.target`; without it,
portals and D-Bus-activated apps break.
cosmic-greeter is retained. Display-manager changes are the easiest way to lose access to a
machine.
Ships a `hyprcosmic.desktop` session entry **alongside** the existing COSMIC session, so the
working desktop remains selectable at login throughout development.
### Theme importer
`cosmic-conf import-theme <path-or-hyde-branch>` — one-way into `cosmic.conf`, not straight into
cosmic-config, so the result is readable and editable.
1. Parse `hypr.theme` with the Phase 1 parser (same grammar — this is where the syntax choice pays off)
2. Map recognised keys through a translation table into HyprCosmic conf keys
3. Extract the palette; derive COSMIC's palette from border/accent colours via the Builder's
tinting inputs (`neutral_tint`, `accent`, `bg_color`)
4. Install GTK/icon tarballs, register wallpapers
5. Copy `waybar.theme`, `rofi.theme`, `kitty.theme` to their upstream destinations unmodified
6. **Emit an explicit unsupported-keys report** rather than silently dropping — e.g.
`col.active_border: gradient not supported (COSMIC active_hint is solid)`
The report is the honesty mechanism that keeps partial import from feeling broken.
## Rejected alternatives
| Alternative | Why rejected |
|---|---|
| **caffyne-shell as the shell** | Python/GTK3 (93% Python), **no license file** (all rights reserved), 10 weeks old at evaluation. Protocol prerequisites were verified present in cosmic-comp, so this remains technically viable if the license is resolved. |
| **CSS theming inside cosmic-panel** | Requires building a CSS cascade for a retained-mode iced UI. Large new subsystem, and the result still would not consume HyDE's `style.css` verbatim. |
| **Teach cosmic-comp to read `cosmic.conf` natively** | The config surface spans ~25 components; a file parsed inside the compositor could only configure the compositor. Also the largest fork and breaks cosmic-settings outright. |
| **Bidirectional config sync** | Round-tripping a commented file through a KV store reliably is hard; failure mode is silently mangling the user's file. |
| **Patch cosmic-comp before building the config layer** | Nothing usable until late, and the fork would be driven by 136 individual files in the meantime. |
## Risks
| Risk | Mitigation |
|---|---|
| Upstream velocity (46 commits/30 days) makes rebasing costly | Keep patches additive and in new files; defer window rules; upstream Patch A if accepted |
| Theme/decoration schema rows are unverified | Spike 1 gates schema work; rows move to Phase 2 if `gaps` proves derived |
| Losing COSMIC's shell removes the appearance GUI | Accepted and documented; `--diff` makes one-way overwrites visible |
| Compositor work is not verifiable without a real session | Phase 1 is fully testable headless; Phases 23 need a nested or TTY session |
| Naming leans on two projects' marks | Personal fork is fine; rename before any wide distribution |
## Success criteria
1. `cosmic-conf apply` compiles a `cosmic.conf` into cosmic-config and COSMIC live-reloads it.
2. A malformed conf produces a spanned diagnostic and writes nothing.
3. `gaps_in` and `gaps_out` both land — proving projection folding works.
4. waybar runs on cosmic-comp showing workspaces and active window via Patch B, unmodified HyDE config.
5. `import-theme Catppuccin-Mocha` yields a matching palette, wallpaper, rounding and gaps, plus
an accurate unsupported-keys report.
6. `hyprcosmic.desktop` is selectable at login and the stock COSMIC session still works.
@@ -0,0 +1,88 @@
# A session that came up with no input, once, on 2026-08-10
**Status: not root-caused. Not reproduced since. Closed deliberately, not fixed.**
This is written down because the next person to hit it -- probably us -- will
otherwise start the same investigation from scratch, and because most of the
value here is the list of things it is *not*.
## What happened
One HyprCosmic session came up with a working bar and a blank desktop below it,
and no keyboard or pointer input reached the compositor at all. No binding
fired. The session had to be left via a VT switch.
After a reboot, the same configuration and the same binaries came up fine.
Super+Return, Super+A and a bare Super tap were all confirmed working by the
user, with independent evidence from a watcher process:
```
12:07:48 SPAWN pid=6264 exe=/usr/bin/rofi cmd=rofi -show drun
12:07:50 SPAWN pid=6433 exe=/usr/bin/rofi cmd=rofi -show drun
12:07:51 EVENT activewindow>>com.system76.CosmicTerm,dingo@fedora:~ - COSMIC Terminal
```
Super+Return shows as a window event rather than a spawn because cosmic-term is
single-instance: the binding fired, the existing process took the request.
## Ruled out, with the evidence
Each of these was a live hypothesis that turned out to be wrong. They are listed
so nobody re-runs them.
- **The fork's own patches.** The blank screen was a separate bug entirely (a
missing `awww img` call in autostart -- the daemon was running and drawing
nothing, so nothing reported a wallpaper missing). Patch B's IPC answered
queries correctly throughout. Neither patch touches input.
- **Events dropped by `seats.for_device()` returning `None`.** This does drop
input silently (`src/input/mod.rs:208-215`), which made it an attractive
theory. But input demonstrably works on the same build, so whatever the fault
was, it was not a permanent property of this code.
- **The modifier-only Super binding swallowing Super+Return.** It cannot. The
match loop at `src/input/mod.rs:1915-1955` sets `modifiers_shortcut_queue` on
press and fires on release, and critically it *does not early-return*, so a
modifier-only binding cannot consume a normal binding sharing its modifier.
- **A shortcuts-config race at login.** The config's mtime was 08:49; the
session started at 09:59:46. Nothing was being written during startup.
- **`Error reading from session socket` and `Unable to become drm master`.**
Both appear in the log. Both also appear in stock COSMIC logins on this
machine, so neither is a fork symptom. (An earlier claim in this project that
the DRM message was absent post-reboot was wrong; it is present twice, for
PID 1609.)
## The one thing that is suspicious
The broken session was the **fourth** compositor start on that boot: 08:06 (from
`target/debug`), 08:08, 08:19, and 09:59. Every other session on that boot, and
every session since a reboot, has been fine.
That points at accumulated per-boot session/seat state -- a previous compositor
not having fully released its seat, or logind still holding devices for a
session that had gone away -- rather than at anything in the configuration or
the code. It is a guess. It was not confirmed, and confirming it would mean
deliberately cycling compositors on a live desktop.
## If it happens again
Collect *before* rebooting, because a reboot destroys the only evidence:
1. `loginctl list-sessions` and `loginctl session-status` for each -- look for
more than one active session, or a session in state `closing`.
2. `ls -l /dev/input/by-path/` and whether the compositor's PID holds any of
them open (`ls -l /proc/<pid>/fd | grep event`).
3. The session log with `RUST_LOG=cosmic_comp::input=trace`. The logger honours
`RUST_LOG` via `EnvFilter::try_from_default_env()` before adding its own
`cosmic_comp={warn|debug}` directives, and those are less specific, so the
trace directive wins.
4. Whether `libinput debug-events` (as root, on a VT) sees the devices at all.
That splits the fault cleanly: if libinput sees nothing, it is below the
compositor and nothing in this repo can be the cause.
Do **not** try to clean up by name-matching processes. `pkill cosmic-*` and
friends have twice killed this user's live desktop. Kill by a PID captured at
spawn, or a process group after `setsid`, and confirm with
`readlink /proc/<pid>/exe` first.
+34 -2
View File
@@ -8,12 +8,17 @@ build:
{{ just }} cosmic-applibrary/build-release
{{ just }} cosmic-bg/build-release
{{ make }} -C cosmic-comp all
# cargo directly, not `just cosmic-conf/build-release`: cosmic-conf is not a
# submodule, it is a crate in this repository, and it has no Justfile of its
# own to delegate to.
cargo build --release --manifest-path cosmic-conf/Cargo.toml
{{ just }} cosmic-edit/build-release
{{ just }} cosmic-files/build-release
{{ just }} cosmic-greeter/build-release
{{ just }} cosmic-idle/build-release
{{ just }} cosmic-initial-setup/build-release
{{ just }} cosmic-launcher/build-release
{{ just }} cosmic-monitor/build-release
{{ just }} cosmic-notifications/build-release
{{ just }} cosmic-osd/build-release
{{ just }} cosmic-panel/build-release
@@ -28,13 +33,25 @@ build:
{{ make }} -C cosmic-wallpapers all
{{ make }} -C cosmic-workspaces-epoch all
{{ just }} pop-launcher/build-release
{{ make }} -C xdg-desktop-portal-cosmic all
# `just`, not `make`. Upstream cosmic-epoch still says
# `{{ make }} -C xdg-desktop-portal-cosmic all` here, and at the submodule
# commit both it and this fork pin (f211aa37, epoch-1.5.0) the portal has no
# Makefile at all -- it moved to a justfile and the meta-repository's recipe
# was never updated. `just build` upstream therefore fails on this line
# after compiling all 26 other components, which is presumably why it went
# unnoticed: distributions build COSMIC one component at a time and never
# take this path.
#
# `build` rather than `build-release`: the portal has no build-release
# recipe. Its `build` defaults to debug='0', which selects --release.
{{ just }} xdg-desktop-portal-cosmic/build
install rootdir="" prefix="/usr/local": build
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-applets/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-applibrary/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-bg/install
{{ make }} -C cosmic-comp install DESTDIR={{rootdir}} prefix={{prefix}}
install -Dm0755 cosmic-conf/target/release/cosmic-conf {{rootdir}}{{prefix}}/bin/cosmic-conf
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-edit/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-files/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-greeter/install
@@ -42,6 +59,7 @@ install rootdir="" prefix="/usr/local": build
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-idle/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-initial-setup/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-launcher/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-monitor/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-notifications/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-osd/install
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-panel/install
@@ -56,7 +74,19 @@ install rootdir="" prefix="/usr/local": build
{{ make }} -C cosmic-wallpapers install DESTDIR={{rootdir}} prefix={{prefix}}
{{ make }} -C cosmic-workspaces-epoch install DESTDIR={{rootdir}} prefix={{prefix}}
{{ just }} rootdir={{rootdir}} pop-launcher/install
{{ make }} -C xdg-desktop-portal-cosmic install DESTDIR={{rootdir}} prefix={{prefix}}
# See the note in `build`: this is a justfile, not a Makefile, so the
# arguments are rootdir/prefix rather than DESTDIR/prefix.
{{ just }} rootdir={{rootdir}} prefix={{prefix}} xdg-desktop-portal-cosmic/install
# The waybar and rofi assets, and the power menu. Last, because it is the
# only step that prints a warning worth reading: several of these files name
# /usr/share/hyprcosmic as a literal -- a .rasi has no variables and the
# autostart file is not a shell -- so a prefix other than /usr installs them
# somewhere they will not be looked for. The script says which files.
#
# --no-session because cosmic-session/install above already placed
# start-hyprcosmic and hyprcosmic.desktop, and installing them twice would
# only make it unclear which recipe owns them.
PREFIX={{prefix}} DESTDIR={{rootdir}} ./tools/install-assets.sh --no-session
_mkdir dir:
mkdir -p dir
@@ -78,12 +108,14 @@ clean:
rm -rf cosmic-applibrary/target
rm -rf cosmic-bg/target
rm -rf cosmic-comp/target
rm -rf cosmic-conf/target
rm -rf cosmic-edit/target
{{ just }} cosmic-files/clean
rm -rf cosmic-greeter/target
{{ just }} cosmic-idle/clean
{{ just }} cosmic-initial-setup/clean
rm -rf cosmic-launcher/target
{{ just }} cosmic-monitor/clean
rm -rf cosmic-panel/target
rm -rf cosmic-player/target
rm -rf cosmic-notifications/target
+60
View File
@@ -0,0 +1,60 @@
# HyprCosmic, as one Arch package.
#
# There is no build() here, and no source array. This PKGBUILD packages a tree
# that `just install` has already staged, which tools/make-packages.sh points at
# through $HYPRCOSMIC_STAGEDIR. The reasoning is in packaging/fedora/hyprcosmic.spec
# and applies identically: building 27 Rust components a second time under
# makepkg, to produce bytes that already exist, costs hours and creates a way
# for the packaged desktop and the built one to drift apart.
#
# The consequence, same as there: this package is only valid on the Arch that
# built it. make-packages.sh builds it inside archlinux:base-devel so that is a
# current Arch rather than whatever the host is.
pkgname=hyprcosmic
pkgver=${HYPRCOSMIC_VERSION:-0.1.0}
pkgrel=1
pkgdesc="COSMIC configured in Hyprland's idiom, with a HyDE shell"
arch=('x86_64')
url="https://github.com/outbackdingo/hyprcosmic"
license=('GPL-3.0-only')
# The HyDE shell. Without these the session starts to a blank screen: no bar,
# no launcher, no wallpaper.
depends=('waybar' 'rofi-wayland' 'wayland' 'libxkbcommon' 'libinput' 'seatd'
'mesa' 'pixman' 'libdisplay-info' 'systemd-libs')
# awww is in the AUR rather than in the repositories, so it cannot be a hard
# depends without making the package uninstallable for anyone who has not built
# it. Without it there is no wallpaper, which is recoverable; an unsatisfiable
# dependency is not.
optdepends=('awww: wallpaper daemon, required for HyDE theme wallpapers'
'ttf-nerd-fonts-symbols: glyphs the waybar config draws with'
'qt5ct: Qt application theming to match the GTK theme')
# What "complete replacement" means in packaging terms. Every path this writes
# under /usr/bin is one cosmic-comp and cosmic-session also own, so the two
# cannot coexist -- which is correct, they are two builds of the same programs.
#
# conflicts without replaces/provides-driven auto-removal: pacman stops and
# names the conflict rather than quietly removing the desktop the machine is
# currently running.
conflicts=('cosmic-comp' 'cosmic-session')
provides=("cosmic-comp=$pkgver" "cosmic-session=$pkgver")
options=('!strip' '!debug')
package() {
# Set by make-packages.sh. Failing loudly here beats producing an empty
# package, which is what a bare `cp -a "$unset/."` would do.
if [ -z "$HYPRCOSMIC_STAGEDIR" ] || [ ! -d "$HYPRCOSMIC_STAGEDIR/usr" ]; then
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see tools/make-packages.sh" >&2
return 1
fi
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
# The two files a broken install shows up in first: a bad Exec line puts an
# entry on the greeter's menu that fails silently when it is chosen.
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/hyprcosmic.desktop"
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/cosmic.desktop"
}
+32
View File
@@ -0,0 +1,32 @@
Package: hyprcosmic
Version: @VERSION@
Architecture: amd64
Maintainer: dingo <[email protected]>
Section: x11
Priority: optional
Homepage: https://github.com/outbackdingo/hyprcosmic
Installed-Size: @INSTALLED_SIZE@
Depends: @SHLIB_DEPENDS@
Recommends: waybar, rofi
Suggests: fonts-hack-ttf, qt5ct
Conflicts: cosmic-comp, cosmic-session
Provides: cosmic-comp, cosmic-session
Replaces: cosmic-comp, cosmic-session
Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
Hyprland's idiom and wears a HyDE-style shell.
.
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
lines and $variables -- is compiled into COSMIC's own configuration tree by
cosmic-conf. The file is the source of truth: keys it names are applied at
every login over whatever the settings UI last stored, and keys it does not
name are left alone.
.
The shell is waybar, rofi and awww in place of cosmic-panel, cosmic-launcher
and cosmic-bg, and HyDE themes are imported directly. The compositor is
COSMIC's, with a Hyprland-compatible IPC socket so that HyDE's scripts and
waybar's hyprland modules work unmodified.
.
This package replaces the distribution's COSMIC. It installs both session
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one,
both served by these binaries.
+123
View File
@@ -0,0 +1,123 @@
# HyprCosmic, as one RPM.
#
# WHY THIS REPACKS RATHER THAN REBUILDS
# -------------------------------------
# There is no %build here. The spec packages a tree that `just install` has
# already produced, which .github/workflows/packages.yml stages and passes in as
# --define "stagedir ...".
#
# The alternative -- a spec that runs `just build` itself under rpmbuild -- is
# the more orthodox shape and is wrong for this project. `just build` compiles
# 27 Rust components; doing it a second time inside rpmbuild to produce bytes
# identical to the ones already sitting in the tree costs hours and buys
# nothing. Worse, it would let the packaged desktop and the `just install`
# desktop drift apart, and the whole point of shipping a package is that the
# two are the same thing.
#
# The cost of this choice, stated plainly: the resulting RPM is only valid on
# the distribution it was built on. Nothing here is statically linked, so an RPM
# built on Fedora 44 assumes Fedora 44's glibc, wayland, libinput and mesa. Build
# it on the release you intend to install it on. The workflow does that by
# running the whole job inside a container of the target distribution.
#
# WHY IT IS ONE PACKAGE AND NOT TWENTY-SEVEN
# ------------------------------------------
# Fedora splits COSMIC into a package per component, which is right for a
# distribution tracking upstream. This is a fork that replaces the desktop as a
# unit: the compositor, the session and the config compiler are versioned and
# tested together, and there is no supported combination in which you take the
# HyprCosmic cosmic-comp and the distribution's cosmic-session. One package is
# an accurate description of what is actually supported.
%global _hardened_build 1
# Debuginfo extraction re-links every binary in the tree and would add an hour
# to a package whose binaries were built elsewhere anyway. There is nothing to
# strip usefully here.
%global debug_package %{nil}
Name: hyprcosmic
Version: %{?ver}%{!?ver:0.1.0}
Release: %{?rel}%{!?rel:1}%{?dist}
Summary: COSMIC configured in Hyprland's idiom, with a HyDE shell
License: GPL-3.0-only
URL: https://github.com/outbackdingo/hyprcosmic
BuildArch: x86_64
# These are what "complete replacement" means in packaging terms. Every path
# this package writes under /usr/bin and /usr/share/cosmic is owned by one of
# these on a stock Fedora, so the two cannot be installed at once -- which is
# correct, because they are two builds of the same programs.
#
# Conflicts rather than Obsoletes, deliberately. Obsoletes would let a routine
# `dnf install hyprcosmic` quietly remove the desktop the machine is currently
# running. Conflicts stops and says so, and removing the COSMIC packages stays
# something a person decides to do rather than something a resolver does on
# their behalf.
Conflicts: cosmic-comp
Conflicts: cosmic-session
# What it stands in for, so anything depending on a COSMIC session is satisfied.
Provides: cosmic-comp = %{version}-%{release}
Provides: cosmic-session = %{version}-%{release}
# The HyDE shell. These are separate programs this fork drives rather than
# builds, and without them the session starts to a blank screen with no bar and
# no launcher.
Requires: waybar
Requires: rofi-wayland
# The wallpaper daemon. Recommends rather than Requires because it lives in the
# alebastr/sway-extras COPR rather than in Fedora proper, and a hard dependency
# that cannot resolve would make this package uninstallable on a machine that
# has not enabled that repository. Without it you get no wallpaper; with it and
# no theme imported, you also get no wallpaper. Both are recoverable; an
# unsatisfiable dependency is not.
Recommends: awww
# Nerd Font glyphs are most of what the bar draws.
Recommends: nerd-fonts
%description
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
Hyprland's idiom and wears a HyDE-style shell.
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
lines and $variables -- is compiled into COSMIC's own configuration tree by
cosmic-conf. The file is the source of truth: keys it names are applied at every
login over whatever the settings UI last stored, and keys it does not name are
left alone.
The shell is waybar, rofi and awww in place of cosmic-panel, cosmic-launcher and
cosmic-bg, and HyDE themes are imported directly. The compositor is COSMIC's,
with a Hyprland-compatible IPC socket so that HyDE's scripts and waybar's
hyprland modules work unmodified.
This package replaces the distribution's COSMIC. It installs both session
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one,
both served by these binaries.
%prep
# Nothing to unpack. See the note at the top of this file.
%install
test -n "%{stagedir}" || { echo "define stagedir: see .github/workflows/packages.yml" >&2; exit 1; }
test -d "%{stagedir}/usr" || { echo "%{stagedir}/usr missing; run just install first" >&2; exit 1; }
cp -a "%{stagedir}/." "%{buildroot}/"
# The desktop entries are the two files a broken install shows up in first, so
# they are validated rather than assumed. A .desktop with a bad Exec line puts
# an entry on the greeter's menu that fails silently when chosen.
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/hyprcosmic.desktop"
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/cosmic.desktop"
# Generated by the workflow from the staged tree rather than written out here.
# A hand-maintained list across 27 components would be wrong within a week, and
# wrong in the direction that ships a package missing files nobody notices until
# a login fails.
%files -f %{filelist}
%changelog
* Sun Aug 10 2026 dingo <[email protected]> - 0.1.0-1
- First package of the fork: COSMIC replaced as a unit, HyDE shell, cosmic-conf.
+2 -1
View File
@@ -3,13 +3,14 @@
set -e
# This should be the _next_ epoch version
version=1.0.6
version=1.5.0
subject="Epoch ${version} version update"
description="Generated by cosmic-epoch scripts/version-update.sh"
repos=(
cosmic-edit
cosmic-files
cosmic-monitor
cosmic-player
cosmic-store
cosmic-term
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/bash
#
# Install the parts of HyprCosmic that live outside a user's home directory.
#
# WHY THIS SCRIPT EXISTS
# ----------------------
# Everything here was placed by hand with `sudo install` while the desktop was
# being built, which left two problems that this script exists to close:
#
# 1. A fresh machine has none of it. rofi's config.rasi @imports
# /usr/share/hyprcosmic/rofi/{palette,rules}.rasi by absolute path, and a
# missing @import is an error rofi renders *in place of the launcher*
# rather than a warning it skips. Miss those two files and Super+A shows a
# parse error instead of a menu.
#
# 2. Hand-installed files drift. /usr/bin/start-hyprcosmic silently gained a
# session-logging block during debugging that never made it back to the
# copy under version control; nothing noticed until the two were diffed on
# a hunch. `--check` is the cure: it compares every managed file against
# its source and exits non-zero on any difference.
#
# WHAT IT DOES NOT DO
# -------------------
# Per-user files. `cosmic-conf import-theme --assets` writes those, because they
# depend on the installed theme and on $HOME; see PER_USER below for the list
# and its owner. The cosmic-conf, cosmic-session and cosmic-comp binaries are
# also out of scope. The top-level justfile installs them -- cosmic-conf from
# its own `install` line, the other two from their component's recipe -- and
# they are build outputs, so comparing them byte-for-byte here would only ever
# report a rebuild.
#
# Usage:
# tools/install-assets.sh install (needs write access to PREFIX)
# tools/install-assets.sh --check compare only; exit 1 on any drift
# tools/install-assets.sh --dry-run print what install would do
# tools/install-assets.sh --no-session skip start-hyprcosmic and the .desktop
#
# Environment:
# PREFIX install prefix, default /usr (see the warning below)
# DESTDIR staging root for packaging, prepended to every path
set -uo pipefail
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
PREFIX="${PREFIX:-/usr}"
DESTDIR="${DESTDIR:-}"
MODE=install
WITH_SESSION=1
# Kept for the "re-run as sudo" hint, which has to quote the invocation the user
# actually made; $@ is long gone by the time a write fails.
ARGV=("$@")
die() { printf 'install-assets: %s\n' "$*" >&2; exit 1; }
warn() { printf 'install-assets: %s\n' "$*" >&2; }
while (($#)); do
case "$1" in
--check) MODE=check ;;
--dry-run) MODE=dry-run ;;
--no-session) WITH_SESSION=0 ;;
-h|--help) sed -n '2,/^set -uo/p' "${BASH_SOURCE[0]}" | sed 's/^# \?//;$d'; exit 0 ;;
*) die "unknown argument: $1 (try --help)" ;;
esac
shift
done
# Files under config/ that belong to the system, as "source:destination:mode".
# Destinations are relative to $PREFIX.
SHARED=(
"config/waybar/bridge-hyde.css:share/hyprcosmic/waybar/bridge-hyde.css:644"
"config/waybar/config.jsonc:share/hyprcosmic/waybar/config.jsonc:644"
"config/waybar/palette.css:share/hyprcosmic/waybar/palette.css:644"
"config/waybar/rules.css:share/hyprcosmic/waybar/rules.css:644"
"config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644"
"config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644"
"config/bin/hyprcosmic-powermenu:bin/hyprcosmic-powermenu:755"
)
# The session entry point. Kept apart from SHARED because it is versioned in the
# cosmic-session fork rather than in this repository -- that fork is a separate
# checkout and may simply be absent, in which case these are skipped.
SESSION=(
"cosmic-session/data/start-hyprcosmic:bin/start-hyprcosmic:755"
"cosmic-session/data/hyprcosmic.desktop:share/wayland-sessions/hyprcosmic.desktop:644"
)
# Files under config/ that produce a SHARED file rather than being one. They are
# inputs to a generator and have no place on the system: installing the template
# would put a file full of @@TOKEN@@ placeholders next to the real config, and
# whichever one a future reader opened first would be a coin toss.
SOURCES=(
"config/waybar/config.jsonc.in" # -> config/waybar/config.jsonc
"config/waybar/generate-config.py" # the generator, and the icon table
)
# Files under config/ that are deliberately NOT installed here, each with the
# thing that does install it. This list is not decoration: the audit below
# refuses to run unless every file under config/ appears in exactly one of the
# three lists, so adding a file forces a decision about where it belongs instead
# of letting it be quietly left out of all of them.
PER_USER=(
"config/autostart" # ~/.config/hyprcosmic/autostart, by hand
"config/cosmic.conf" # ~/.config/hyprcosmic/cosmic.conf, by hand
"config/waybar/style.css" # ~/.config/hyprcosmic/waybar/style.css; @imports a sibling theme.css
"config/rofi/config.rasi" # ~/.config/rofi/config.rasi, by `cosmic-conf import-theme --assets`
)
audit_config_tree() {
local f rel known=" ${PER_USER[*]} ${SOURCES[*]} " unclassified=()
# Built with a loop, not `${SHARED[*]%%:*}`: that form strips the suffix
# from the first element only and silently keeps the rest whole.
for f in "${SHARED[@]}"; do known+="${f%%:*} "; done
while IFS= read -r -d '' f; do
rel="${f#"$REPO"/}"
[[ "$known" == *" $rel "* ]] || unclassified+=("$rel")
done < <(find "$REPO/config" -type f -print0 | sort -z)
((${#unclassified[@]} == 0)) || die "not listed as shared or per-user: ${unclassified[*]}
Add each to SHARED or PER_USER in $(basename "${BASH_SOURCE[0]}") and say which."
}
# The prefix is only half honoured, and pretending otherwise would be worse than
# saying so. Some consumers name /usr/share/hyprcosmic as a literal because they
# have no way to interpolate one: rofi's .rasi has no variables, and the
# autostart file is explicitly not a shell. Report them by grepping rather than
# from a hardcoded list, so this warning cannot go stale.
check_prefix_assumptions() {
[[ "$PREFIX" == /usr ]] && return 0
local hits
hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ 2>/dev/null | sort | tr '\n' ' ')"
[[ -z "$hits" ]] && return 0
warn "PREFIX=$PREFIX, but these name /usr/share/hyprcosmic literally and cannot interpolate it:"
warn " $hits"
warn "they will keep reading /usr/share unless you edit them; rofi will show a parse error if it is empty"
}
# Fail before touching anything rather than half way through. The nearest
# existing ancestor is what matters: install(1) creates the leaf directories, so
# an absent share/hyprcosmic/rofi is fine as long as share/ can be written.
assert_writable() {
local dest="$1" dir
dir="$(dirname "$dest")"
while [[ ! -e "$dir" && "$dir" != / ]]; do dir="$(dirname "$dir")"; done
[[ -w "$dir" ]] && return 0
if ((EUID != 0)); then
die "$dir is not writable. Re-run as: sudo ${BASH_SOURCE[0]}${ARGV[*]:+ ${ARGV[*]}}"
fi
die "$dir is not writable, even as root"
}
status=0
installed=0 skipped=0 differs=0
handle() {
local src="$REPO/$1" dest="$DESTDIR$PREFIX/$2" mode="$3"
if [[ ! -f "$src" ]]; then
warn "missing source, skipped: $1"
skipped=$((skipped + 1))
return
fi
case "$MODE" in
check)
if [[ ! -e "$dest" ]]; then
printf ' MISSING %s\n' "$dest"
differs=$((differs + 1))
elif cmp -s "$src" "$dest"; then
printf ' ok %s\n' "$dest"
else
printf ' DIFFERS %s\n' "$dest"
differs=$((differs + 1))
fi
;;
dry-run)
printf ' would install -m %s %s -> %s\n' "$mode" "$1" "$dest"
;;
install)
assert_writable "$dest"
install -D -m "$mode" "$src" "$dest" || die "failed to install $dest"
printf ' %s\n' "$dest"
installed=$((installed + 1))
;;
esac
}
audit_config_tree
[[ "$MODE" == install ]] && check_prefix_assumptions
targets=("${SHARED[@]}")
if ((WITH_SESSION)); then
if [[ -d "$REPO/cosmic-session/data" ]]; then
targets+=("${SESSION[@]}")
else
warn "cosmic-session/data is absent; skipping the session entry point"
fi
fi
case "$MODE" in
check) echo "Checking against $DESTDIR$PREFIX:" ;;
dry-run) echo "Dry run against $DESTDIR$PREFIX:" ;;
install) echo "Installing to $DESTDIR$PREFIX:" ;;
esac
for spec in "${targets[@]}"; do
IFS=: read -r src dest mode <<<"$spec"
handle "$src" "$dest" "$mode"
done
case "$MODE" in
check)
if ((differs)); then
echo "$differs file(s) missing or out of date; re-run without --check to fix" >&2
status=1
else
echo "All ${#targets[@]} file(s) match."
fi
;;
install)
echo "Installed $installed file(s)."
((skipped)) && echo "Skipped $skipped missing source(s)." >&2
;;
esac
exit $status
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/bash
#
# Run the forked cosmic-session nested inside the live desktop, safely.
#
# WHY THIS SCRIPT EXISTS
# ----------------------
# On 2026-08-10 an ad-hoc version of this test logged the developer out of their
# own desktop. Two independent mistakes did it, and both are easy to repeat by
# hand, so the test lives in a script instead:
#
# 1. `pkill -x cosmic-session` matched the *real* session leader. The fork and
# the system COSMIC ship binaries with the same name, so no name-based
# match can distinguish them. This script therefore never uses pkill or
# pgrep; it kills the process group it created, by ID.
#
# 2. A nested cosmic-session on the shared session bus takes the well-known
# D-Bus name `com.system76.CosmicSession` away from the running session
# (journal: "Connection `:1.3` lost name `com.system76.CosmicSession`").
# That destabilises the outer desktop before anything is even killed. This
# script always runs under `dbus-run-session`, so the nested session gets a
# private bus and cannot touch the real one's names.
#
# Nesting cosmic-comp alone is safe and does not need any of this; the hazard is
# specific to running a second cosmic-session.
#
# Usage: tools/nested-session.sh [seconds] [-- extra env assignments]
# e.g. tools/nested-session.sh 12 -- HYPRCOSMIC_PROFILE=hyprcosmic
set -uo pipefail
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
SESSION_BIN="$REPO/cosmic-session/target/debug/cosmic-session"
COMP_BIN="$REPO/cosmic-comp/target/debug/cosmic-comp"
DURATION="${1:-12}"
shift || true
[[ "${1:-}" == "--" ]] && shift
die() { printf 'nested-session: %s\n' "$*" >&2; exit 1; }
# Refuse to run outside a Wayland session. Without a host compositor the winit
# backend would fall back to DRM and try to take over the real display.
[[ -n "${WAYLAND_DISPLAY:-}" ]] || die "no WAYLAND_DISPLAY; refusing to run (would grab the DRM device)"
[[ -x "$SESSION_BIN" ]] || die "not built: $SESSION_BIN"
[[ -x "$COMP_BIN" ]] || die "not built: $COMP_BIN"
command -v dbus-run-session >/dev/null || die "dbus-run-session is required for bus isolation"
# Record the live session's leader purely so the exit check can prove we did not
# disturb it. Asked of logind rather than matched by process name: a name-based
# lookup is what destroyed the developer's session twice, once in the very test
# written to prove name matching was unsafe. There is no `ps -C cosmic-...`
# anywhere in this file, deliberately.
OUTER_LEADER="$(loginctl show-session "${XDG_SESSION_ID:-}" -p Leader --value 2>/dev/null)"
LOG="$(mktemp -t nested-session.XXXXXX.log)"
echo "nested-session: logging to $LOG"
echo "nested-session: live session leader=$OUTER_LEADER (must survive)"
# setsid puts the whole tree in a fresh process group whose ID equals the child
# PID, so one negative kill reaps the session, the compositor and every
# component it spawned -- with no pattern matching anywhere.
setsid env \
COSMIC_BACKEND=winit \
RUST_LOG="${RUST_LOG:-info}" \
"$@" \
dbus-run-session -- "$SESSION_BIN" "$COMP_BIN" >"$LOG" 2>&1 &
PGID=$!
cleanup() {
# Negative PID = process group. Never a name.
kill -TERM -"$PGID" 2>/dev/null
for _ in $(seq 20); do
kill -0 -"$PGID" 2>/dev/null || break
sleep 0.25
done
kill -KILL -"$PGID" 2>/dev/null
# An abruptly-killed compositor leaves its IPC directory behind, so drop any
# whose owning PID is gone. Matching is on the PID embedded in the name.
for dir in "${XDG_RUNTIME_DIR:?}"/hypr/cosmic_*; do
[[ -d "$dir" ]] || continue
pid="${dir##*/cosmic_}"; pid="${pid%%_*}"
kill -0 "$pid" 2>/dev/null || rm -rf "$dir"
done
}
trap cleanup EXIT INT TERM
sleep "$DURATION"
cleanup
trap - EXIT INT TERM
# The whole point: confirm the developer still has a desktop.
status=0
if [[ -n "$OUTER_LEADER" ]] && ! kill -0 "$OUTER_LEADER" 2>/dev/null; then
echo "nested-session: FAIL - live session leader $OUTER_LEADER died during the test" >&2
status=1
else
echo "nested-session: live session survived"
fi
echo "--- log: $LOG ---"
exit $status
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Check that the fork's Hyprland IPC is reachable the way a real client reaches it.
Run this inside a HyprCosmic session after installing a new cosmic-comp. It
exercises the two things that are easy to get wrong and impossible to see from
the compositor's own log:
1. The socket *names*. Every Hyprland client -- waybar's hyprland/* modules,
hyprctl, eww, ags -- opens `$XDG_RUNTIME_DIR/hypr/$HIS/.socket.sock` and
gives up if it is absent. waybar reports it once, at startup, as
"Couldn't connect to ... (3)" and then disables the module, so a bar with
a silently missing workspace widget is the only symptom you get.
2. The dispatch (write) endpoint, which is what makes clicking a workspace on
the bar actually switch to it rather than just look clickable.
Nothing here changes configuration. `dispatch workspace` moves the focused
workspace, which is runtime state, so this script does disturb what you are
looking at: it returns to the workspace you started on when it finishes.
Exit status is 0 only if every check passed.
"""
import os
import socket
import sys
RESET, RED, GREEN, DIM = "\033[0m", "\033[31m", "\033[32m", "\033[2m"
failures = []
def result(ok: bool, label: str, detail: str = "") -> bool:
mark = f"{GREEN}ok{RESET}" if ok else f"{RED}FAIL{RESET}"
print(f" [{mark}] {label}")
if detail:
for line in str(detail).splitlines():
print(f" {DIM}{line}{RESET}")
if not ok:
failures.append(label)
return ok
def socket_dir() -> str:
runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
base = os.path.join(runtime, "hypr")
if not os.path.isdir(base):
print(f"{RED}No {base}. Is this a HyprCosmic session?{RESET}")
sys.exit(2)
sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
if not sig:
# Newest instance, so this still works from a terminal that predates it.
entries = sorted(
(e for e in os.listdir(base) if os.path.isdir(os.path.join(base, e))),
key=lambda e: os.stat(os.path.join(base, e)).st_mtime,
)
if not entries:
print(f"{RED}No instance directory under {base}.{RESET}")
sys.exit(2)
sig = entries[-1]
print(f"{DIM}HYPRLAND_INSTANCE_SIGNATURE unset; using newest: {sig}{RESET}")
return os.path.join(base, sig)
def request(path: str, payload: str, timeout: float = 2.0) -> str:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
s.connect(path)
s.sendall(payload.encode())
chunks = []
while True:
data = s.recv(8192)
if not data:
break
chunks.append(data)
return b"".join(chunks).decode(errors="replace")
def main() -> int:
d = socket_dir()
req_path = os.path.join(d, ".socket.sock")
evt_path = os.path.join(d, ".socket2.sock")
print(f"\ninstance dir: {d}\n")
print("socket names (the names clients actually open)")
have_req = result(os.path.exists(req_path), ".socket.sock exists")
result(os.path.exists(evt_path), ".socket2.sock exists")
stale = [n for n in (".socket", ".socket2") if os.path.exists(os.path.join(d, n))]
result(not stale, "no unsuffixed leftovers", ", ".join(stale) if stale else "")
if not have_req:
print(f"\n{RED}Request socket missing; cannot go further.{RESET}")
print("An old cosmic-comp is probably still running -- the rename only")
print("takes effect for a session started after installing the binary.")
return 1
print("\nread endpoints")
active = None
for cmd in ("workspaces", "activeworkspace", "activewindow", "clients", "monitors"):
try:
reply = request(req_path, f"j/{cmd}")
ok = reply.strip().startswith(("{", "["))
result(ok, f"j/{cmd}", "" if ok else f"unexpected reply: {reply[:200]}")
if cmd == "activeworkspace" and ok:
import json
active = json.loads(reply).get("id")
except OSError as e:
result(False, f"j/{cmd}", e)
print("\nunknown commands are refused, not guessed at")
for cmd in ("bogus", "dispatch exec rofi", "dispatch killactive",
"dispatch workspace +1", "dispatch workspace 0"):
try:
reply = request(req_path, cmd).strip()
# An unparsed request gets no useful answer; what matters is that it
# is not silently treated as something else.
result(not reply.startswith("ok"), f"refuses {cmd!r}", f"reply: {reply[:120]}")
except OSError as e:
result(False, f"refuses {cmd!r}", e)
print("\nwrite endpoint")
if active is None:
result(False, "know the current workspace to return to")
else:
target = 2 if active != 2 else 1
try:
reply = request(req_path, f"dispatch workspace {target}").strip()
result(reply == "ok", f"dispatch workspace {target}", f"reply: {reply[:120]}")
back = request(req_path, f"dispatch workspace {active}").strip()
result(back == "ok", f"back to workspace {active}", f"reply: {back[:120]}")
except OSError as e:
result(False, "dispatch workspace", e)
print()
if failures:
print(f"{RED}{len(failures)} check(s) failed:{RESET}")
for f in failures:
print(f" - {f}")
return 1
print(f"{GREEN}All checks passed.{RESET}")
return 0
if __name__ == "__main__":
sys.exit(main())