Compare commits

...
52 Commits
Author SHA1 Message Date
gitops c80566768c Record 0.1.1 in the changelog
Three fixes, all of which decide whether a fresh install comes up at all: the
skeleton that seeds ~/.config, a font recommendation that can actually resolve,
and a wallpaper for a machine with no theme yet.
2026-08-12 06:55:40 +07:00
gitops 06f974f09f Say how to take a newer default, since seeding will not
Seeding never overwrites, which is the right contract and also means an
upgrade that improves a shipped default never reaches anyone who already
has that file. Both machines this was tested on keep the old autostart
line that waits for awww in an unbounded loop, and nothing will ever
replace it for them.

That is a trade worth making and not one worth hiding, so document the
way out: diff against the skeleton, then copy if you want it.
2026-08-11 19:11:47 +07:00
gitops e006e95e55 Draw a wallpaper on a machine that has no theme yet
Seeding the config gets the bar and the keybindings up, and then leaves
you looking at a black screen, because the autostart line that sets the
wallpaper named ~/.local/share/wallpapers/hyprcosmic/current directly.
That is a symlink import-theme --assets maintains, so it does not exist
until a theme has been imported -- which on a machine that has just
installed the package it has not.

The same line waited for the daemon with an unbounded loop:

    until awww query >/dev/null 2>&1; do sleep 0.2; done

awww is a Recommends and lives in a COPR, so on a machine that never
enabled that repository nothing ever answered and the loop spun at 5 Hz
for the length of the session.

Both now live in hyprcosmic-wallpaper, which falls back to the
distribution's own images when there is no link yet, gives up on the
daemon after ten seconds, and exits 0 on every path so autostart is never
left supervising a process that cannot finish. Fedora recommends
cosmic-wallpapers so the fallback has something to find; nothing in the
dependency chain pulled it in before.

Verified against all five paths: no awww at all, a daemon that never
answers, a fresh machine with no link, a link pointing at a real file,
and a link left dangling by a deleted theme.
2026-08-11 18:25:24 +07:00
gitops 111f3ade7b Ship the per-user config, and recommend a font that exists
Two things anyone installing v0.1.0 would have hit.

The per-user files were listed in install-assets.sh as installed 'by
hand', which meant a clean machine logged in to a bare compositor with
nothing drawn on it and no binding that opened anything. They are now
installed as a skeleton under share/hyprcosmic/skel, laid out exactly as
they sit under ~/.config, and the session copies what is missing at
login. The audit still forces every file under config/ into exactly one
list, so this could not be quietly skipped again.

theme.css, theme.rasi and local.rasi are new and deliberately empty. They
are the relative @imports of style.css and config.rasi, and a missing
@import is fatal to both consumers rather than a warning they skip: GTK
fails the whole stylesheet, and rofi reports the error in place of the
launcher. Every colour they could define already has a default from
palette.css and palette.rasi, so empty is the correct content until
import-theme writes them. style.css's own header has promised 'a sibling
file we create at install time' since it was written; now there is one.

Recommends: nerd-fonts named a package that is in no Fedora repository,
so it could never be satisfied and the font never arrived -- silently,
which is what Recommends does when it cannot resolve. Fedora has no Nerd
Font at all: texlive-inconsolata-nerd-font lands under texmf-dist and
kitty's SymbolsNerdFont under /usr/lib64/kitty, and fontconfig scans
neither. Recommend font(notosansmono) instead, the fallback rules.rasi
already names, and say in the README how to install the glyphs by hand.
2026-08-11 18:15:44 +07:00
gitops 0e6dcdc606 Say why the release is a draft, now that it installs beside COSMIC 2026-08-11 17:10:35 +07:00
gitops 28042b9879 Install beside COSMIC instead of replacing it
The packages could not be installed. Claiming the cosmic-* names put 62 files
in conflict with 25 distribution packages -- something dnf's depsolve never
reports, because it shows up only in rpm's transaction check -- and the only
way to satisfy that was Conflicts against all 25, which erases cosmic-greeter.
On a stock Fedora COSMIC that is the display manager, so the machine comes
back to a text console with no way to log in and no session to fall back to.

So the fork ships what it actually forks. The three changed binaries take
hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, the workflow reduces
the staged tree to those plus this fork's own assets, and cosmic-settings, the
portal, the OSD and the rest come from the distribution at the version it
tested them at. No Conflicts, no Provides, nothing erased, and the stock
COSMIC entry stays on the greeter's menu to fall back to.

Debian is the exception and keeps the whole desktop, because COSMIC is not
packaged there in any suite: there is nothing to depend on and nothing to
install beside. Its stock session entry is dropped instead, since start-cosmic
execs the cosmic-session this rename takes away.
2026-08-11 14:30:14 +07:00
gitops 5bb057cb71 fedora: version the compat Provides at COSMIC's version, not at ours
`Provides: cosmic-comp = %{version}-%{release}` reads as the obvious thing to
write and is wrong in a way that only shows up at the next boot.

cosmic-greeter requires `cosmic-comp >= 1.5.0`. A Provides of 0.1.0 does not
satisfy that, so dnf's resolution of `install hyprcosmic` is to remove
cosmic-greeter -- which on a stock Fedora COSMIC is the display manager. The
transaction succeeds, nothing warns, and the machine comes back to a text
console.

Verified against the installed set on a Fedora 44 COSMIC machine, where the
same transaction removed cosmic-greeter (`>= 1.5.0`) and cosmic-config-fedora
(`>= 1.0.0~alpha.3`) while leaving cosmic-workspaces alone. Workspaces depends
on a bare `cosmic-comp` with no version, so it is the control: the Provides
mechanism works, only the comparison was failing.

Arch and Debian are not affected and are left alone. Arch's cosmic-greeter
depends on a bare `cosmic-comp` with no constraint, so its provides at $pkgver
resolves; Debian has no cosmic-comp package at all, so its Conflicts and
Provides are precautionary and have nothing to compare against.

Separately, and not a packaging matter: cosmic-session is listed in
/etc/dnf/protected.d/fedora-cosmic.conf on a Fedora COSMIC install. Protected
packages are locked in the solver, so the transaction is unsolvable and
--allowerasing has no effect on it. Installing over a stock COSMIC needs
`--setopt=protected_packages=` on the command line. That is the distribution
declining to let its desktop be removed by accident, which is reasonable, and
overriding it is the operator's call to make explicitly.
2026-08-11 11:12:12 +07:00
gitops 43507504f4 cosmic-conf: windowrule, and the rules it refuses
windowrule = workspace name:web, class:^(vivaldi|firefox)$
    windowrule = workspace 1,        class:^(kitty)$, title:^(dev)$

Pairs with the workspace lines from the previous commit: those make a
named workspace exist, this sends an application to it. `windowrulev2` is
accepted as the same key, since that is the spelling configs in the wild
are written with.

Hyprland's windowrule has around forty actions. One of them is
implemented, because one of them has a COSMIC counterpart, and the other
thirty-nine fail with an explanation rather than parsing into nothing. A
rule that looks right and silently does not fire is the worst outcome
available -- the window opens in the wrong place and there is nothing to
read that says why. So `float` points at the tiling exceptions in
cosmic-settings, which is where floating actually lives; the rest say that
only `workspace` exists here. Matchers that ask about window state --
floating:, fullscreen:, onworkspace: -- say that matching happens once, as
the window opens, so only what the window arrives with can be tested.

Three things the parser has to get right

`+1` is not workspace 1. u32::from_str accepts a leading sign, so
Hyprland's relative target would have parsed as an absolute one and sent
the window somewhere the rule never asked for. The digits are checked
before parsing rather than the result checked after.

`silent` is stripped from the end, not parsed as one word among several,
because a workspace name may contain spaces. `workspace = 2, name:web and
mail` is a legal declaration, so `workspace name:web and mail` has to be a
legal rule.

A rule with neither class nor title is refused. Both are regular
expressions and an empty one matches everything, so the rule that looks
like it does nothing would in fact send the entire session to one
workspace.

Order is the semantics

Rules are emitted in the order they were written and not deduplicated: two
rules can differ only in their title and both be wanted, and the
compositor takes the first that matches, so a file that reads top to
bottom has to be written top to bottom.

The regex dependency

Expressions are compiled here so a broken one is a diagnostic against the
line that wrote it, with the crate's own message pointing at the offending
character, rather than a warning in the compositor log nobody reads.
Pinned to the same major cosmic-comp matches with, so what compiles here
compiles there. Cargo.lock carries the five new entries at the versions
cosmic-comp already resolved.

Verified

24 unit tests in the new module and 6 in resolve, covering the sign trap,
the name with spaces, both spellings of the key, the order of the emitted
list, variable expansion, and that each refused action and matcher carries
its explanation. Not verified here: nothing is compiled on this machine.
2026-08-11 08:22:03 +07:00
gitops 49d5f8b20e cosmic-conf: a fixed set of workspaces, with no compositor change
COSMIC's workspaces are dynamic and there is no setting that turns that
off. ensure_last_empty keeps exactly one trailing empty workspace and
collects every other empty one the moment its last window closes, so
"workspace 4 is the browser" is only true while the browser is open.

The primitive that fixes it already exists. can_auto_remove is

    is_empty() && !has_activation_token() && !pinned

and pinned_workspaces is an existing persisted CosmicCompConfig key that
Workspaces::add_output drains into the first output to appear. So this is
a projection and nothing else: no cosmic-comp patch, no new config key,
and the fast cosmic-conf CI job covers all of it rather than a 2.5 hour
package build per distro.

    workspace = 1, name:term
    workspace = 2, name:web
    workspace = 3, name:code

Three things about the restore path shaped the module.

Restore is positional. PinnedWorkspace has no index field -- the order of
the Vec becomes the order of the workspaces -- so `workspace = 4` cannot
emit one entry. It emits four, with 1..3 unnamed, or the browser
workspace comes back as workspace 1. That is why there is a cap: without
one, `workspace = 1000` is a typo that silently creates a thousand
workspaces rather than a diagnostic.

The dynamic workspace survives. Pinned workspaces are pushed into an
empty WorkspaceSet and ensure_last_empty then appends the usual empty
one, so declaring four gives 1-4 always present and a fifth appearing
when you use it. That is Hyprland's behaviour and not a compromise
either way.

It lands at the next login. Workspaces::new reads the key once at
compositor start and there is no reload path for it, while cosmic-conf
watch is started from the autostart file after COSMIC's own components.
Every other key in this file is live, so the one that is not is worth
saying out loud in cosmic.conf rather than leaving to be discovered.

Ids are generated rather than random

random_workspace_id is format!("{:x}", rand(0..2<<24)), at most seven hex
digits. Ours are FNV-1a of the index with the high nibble forced on, so
always eight -- a collision with a compositor-generated id is impossible
by construction rather than unlikely. FNV is written out rather than
reaching for DefaultHasher, which is explicitly not stable across Rust
releases: the id is what ties a window's saved workspace to the
workspace it reappears on, so it changing under a toolchain bump is a
bug that would be very hard to attribute.

monitor: is refused, not ignored

Hyprland's monitor: is the parameter a user reaches for first and the one
COSMIC cannot honour. output_matches compares the EDID before the name,
so an OutputMatch with edid: None is rejected outright against any output
that reports one, and only falls through to the name when neither side
has an EDID. Every real panel reports one, so a name-only match would
work on a VM and nowhere else. cosmic.conf cannot supply an EDID -- it is
a manufacturer triple, product id, serial and manufacture date read off
the wire by the DRM backend.

So the parameter fails with that explanation, the same way follow_mouse =
2 does. Accepting it and quietly doing nothing was the third option and
the worst of the three. The emitted OutputMatch is empty, which is not a
placeholder: an unmatchable match is how a workspace says it has no
output preference, which is the only thing this file can truthfully say.

tiling: defaults to general:autotile

A PinnedWorkspace carries its own tiling_enabled. Defaulting it to false
would have meant that turning workspaces on quietly turned tiling off for
someone whose whole reason for editing the file was autotile = true, so
resolve reads the folded autotile write and passes it through.

CI

The assets job syntax-checked two of the four shell scripts;
hyprcosmic-fan and hyprcosmic-keybinds were added in fc8c2a7 and never
registered. Both are now checked and shellchecked, and each is checked
with the shell its shebang names -- hyprcosmic-fan is POSIX sh on
purpose, and `bash -n` would accept a bashism that fails where /bin/sh is
dash. All four are clean today, so the list stays a ratchet.

Verified

The RON shape was taken from the struct definitions rather than guessed:
PinnedWorkspace has no serde defaults, so all four fields are emitted,
and the (field: value) / None / Some(x) spelling matches what COSMIC
itself wrote to this machine's xkb_config. 19 unit tests in the new
module and 6 in resolve cover the gap-filling, the id range, the
inherited tiling default, the duplicate-index diagnostic, and that a
quote in a name cannot break out of the RON.

Not verified: nothing was compiled here. cargo test, clippy -D warnings
and the release build run in the cosmic-conf job.
2026-08-11 08:02:41 +07:00
gitops fc8c2a727d waybar: six more modules, and a keyboard reference the session had none of
The bar showed which windows existed but not which one had the keyboard,
and nothing at all about the GPU, the fans, the disk, or a unit that had
failed at boot. Six modules, two of which needed a script because waybar
has no module that fits.

hyprland/window, and no compositor change

Reading the active window needs `j/activewindow` on startup and
`activewindow>>` on .socket2.sock, and hypr_ipc already serves both. A
retitle of the focused window propagates too, which is the case that
usually needs a separate `windowtitle` event: this fork diffs a snapshot
on a 150 ms tick rather than emitting from call sites, so src/hypr_ipc/
sync.rs sees a (class, title) change and cannot miss it. There is already
a test for exactly that -- title_change_alone_emits_activewindow.

The rewrite rules trim the app suffix from titles written for a title bar
rather than a 60-column slot. All five now use the same [-<em dash>]
class. Vivaldi had a bare hyphen because it was written from the live
title -- j/clients reports "Inbox - ... - Gmail - Vivaldi" -- and the
others got the class in a later pass, which left the one rule that would
break if Vivaldi ever switched separators. COSMIC's own applications use
an em dash and not a hyphen, so a rule written with a hyphen matches
nothing there.

custom/fan, because neither of the obvious options can name this sensor

waybar has no fan module. Its temperature module cannot be borrowed: it
divides by 1000 to turn millidegrees into degrees, which renders 2700 rpm
as 2 C. Nor can hwmon-path-abs point at the fans. That option names a
parent directory and takes the one hwmonN inside it, which works for
k10temp and amdgpu because each has exactly one, but asus-nb-wmi has two:

    hwmon9   name=asus                  fan1_input, fan2_input
    hwmon10  name=asus_custom_fan_curve  the curve's set points

Which of the two waybar picked would be down to readdir order. So the
script resolves by content instead -- any hwmon with a fan*_input -- which
also makes it work on hardware that is not this laptop. It prints nothing
where there is no readable fan, and waybar draws an empty custom module as
nothing, so a VM loses the item rather than showing a dead 0 rpm.

hyprcosmic-keybinds, and why it does not read cosmic.conf

The obvious implementation reads cosmic.conf and would be wrong. This
machine's cosmic.conf declares six bindings. The session answers to 122.
The other 116 are COSMIC's defaults, which the fork does not restate
because it has no reason to -- Super+Q closes a window whether or not
anybody wrote it down. A reference showing six entries would not look
broken, it would look complete, while missing every window, workspace and
media key on the machine.

So it reads the three RON files the compositor reads: Shortcuts/v1/
defaults for the built-ins, the user's Shortcuts/v1/custom for what
cosmic-conf projected out of cosmic.conf, and Shortcuts/v1/system_actions
to turn System(Screenshot) into cosmic-screenshot. Without the third, a
third of the list would name internal actions rather than the programs
they launch, which is the half of the question that was being asked.

Parsing RON with sed deserves a defence. The principled home for this is a
`cosmic-conf binds` subcommand, which already has the schema and the bind
grammar -- and is a compiled change and a full package build. The files
are one binding per line and nothing downstream consumes this, so the cost
of being wrong is a mangled row in a help window, not a broken keybinding.
The script refuses to show a list it could not parse rather than showing a
short one.

The binding for it was chosen with the script: Super+K and Super+I are
both focus actions in COSMIC's defaults, and Super+Shift+/ is free.

install-assets: prune dot-directories from the audit

audit_config_tree refuses to run unless every file under config/ is
classified, and it started failing on six gitignored .omc/ state files
that tooling had dropped into config/waybar/. Failing on those trains you
to ignore the one message that catches a genuinely unclassified asset.
Dot files are still walked; only directories are pruned.

Verified before committing

The generated config parses as JSON with all 23 modules defined and none
defined but unplaced. The three stylesheets concatenate and parse clean
through GTK's own CssProvider, which is the parser waybar uses, and every
@colour they name is defined. Both scripts pass a syntax check, and
hyprcosmic-keybinds was run with rofi stubbed -- nothing appeared on the
desktop -- producing 118 rows, which is 116 defaults plus 6 ours less the
4 that override. System() actions resolved to real commands, chords sorted
Super first, and the modifier-only Super binding survived, which a parser
requiring a key field would have dropped.

Every sensor was read before being wired to a module rather than after:
amdgpu edge 55 C, cpu_fan 2700 and gpu_fan 2500 rpm, zero failed units in
both scopes, / at 3 percent.

Not verified: how any of it looks. Rendering needs waybar restarted
against a session that is not at the lock screen.
2026-08-11 07:46:29 +07:00
gitops 5f521529a8 packages: fix the three packaging failures, none of which were the build
Run 31394740824 compiled all 27 components on all three distributions --
two and a half hours each -- staged them, passed the completeness
assertions, and then failed in the packaging step on every one. Three
separate causes, none of them the desktop.

desktop-file-validate rejects DesktopNames (fedora, arch)

    hyprcosmic.desktop: error: file contains key "DesktopNames" in group
    "Desktop Entry", but keys extending the format should start with "X-"

DesktopNames is the key a display manager reads to set
XDG_CURRENT_DESKTOP, so a session entry needs it, but the Desktop Entry
Specification registers keys for application launchers and the validator
has no entry for it. This is not something the fork introduced:
cosmic.desktop is upstream cosmic-session's file, unchanged apart from the
Exec path, and the copy already installed on this machine as
cosmic-session-1.5.0-1.fc44 fails the identical check. Dropping the key
would satisfy the validator and break the session.

So the validation moves out of the three packaging recipes into one step
against the staged tree, and checks what desktop-file-validate would not
have caught anyway: that Exec is absolute and names an executable this
package actually installs. A wrong Exec puts an entry on the greeter's
menu that dies silently when it is chosen, which is the failure that
matters here; a key the validator has not heard of is not.

The check was run against a staged tree built from the real desktop files
before committing: it passes clean, and it rejects an Exec naming a binary
that is not in the tree, an Exec that exists but is not executable, a
relative Exec, a missing DesktopNames, a missing Name, a missing [Desktop
Entry] header and Type=Link, each with a message saying which file and
what.

SIGPIPE under pipefail (debian, and arch next)

    dpkg-deb: error: tar subprocess was killed by signal (Broken pipe)

The Debian package built: 217 MB, correct control, Depends computed by
dpkg-shlibdeps across 17 libraries. It failed on `dpkg-deb --contents
dist/*.deb | head -20` in the verify step -- head closes the pipe after
twenty lines, dpkg-deb dies of SIGPIPE, and Actions runs these steps with
pipefail. Both now write to a file and head the file. The Arch verify step
had the same line waiting for it and is fixed in the same way.

bogus date in %changelog (fedora)

10 August 2026 is a Monday.

Also: the PKGBUILD still pointed at tools/make-packages.sh, which was
deleted when packaging moved to Actions. It now names the two commands
that stage a tree and build from it by hand.
2026-08-10 23:23:35 +07:00
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
71 changed files with 12781 additions and 332 deletions
+218
View File
@@ -0,0 +1,218 @@
# 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.
# Each script is checked with the shell its shebang names rather than with
# bash across the board: hyprcosmic-fan is POSIX sh on purpose, and `bash
# -n` would happily accept a bashism that fails on a system where /bin/sh
# is dash.
- name: Syntax-check the shell scripts
run: |
set -eux
sh -n config/bin/hyprcosmic-fan
bash -n config/bin/hyprcosmic-keybinds
bash -n config/bin/hyprcosmic-powermenu
bash -n tools/install-assets.sh
# Every script is 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-fan \
config/bin/hyprcosmic-keybinds \
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
+556
View File
@@ -0,0 +1,556 @@
# 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 three binaries this fork actually changes, under their own names.
#
# Done on every distribution, because start-hyprcosmic and the session
# entry name these paths and there is no reason for those to differ per
# distribution. cosmic-session takes the compositor as argv[1], so the
# pair is arranged in start-hyprcosmic and needs no source change.
- name: Give this fork's binaries their own names
run: |
set -eux
mv stage/usr/bin/cosmic-comp stage/usr/bin/hyprcosmic-comp
mv stage/usr/bin/cosmic-session stage/usr/bin/hyprcosmic-session
mv stage/usr/bin/cosmic-conf stage/usr/bin/hyprcosmic-conf
# The stock session entry goes with it, on Debian only.
#
# start-cosmic execs /usr/bin/cosmic-session, which the rename above just
# took away, so cosmic.desktop would sit on the greeter's menu and die
# silently when chosen. Everywhere else the reduction below removes both
# and the distribution's own cosmic-session package supplies a working
# pair; on Debian there is no such package, so they are removed here and
# the greeter offers the HyprCosmic entry alone.
- name: Drop the stock session entry it can no longer start
if: matrix.distro == 'debian'
run: |
set -eux
rm -f stage/usr/bin/start-cosmic
rm -f stage/usr/share/wayland-sessions/cosmic.desktop
# HyprCosmic installs beside COSMIC rather than over it, and this is the
# step that makes that true.
#
# `just install` stages the whole desktop, because it builds the whole
# tree. Nearly all of it is byte-identical to what the distribution
# already ships, and the parts that are not are file conflicts that stop
# the install outright -- 62 of them on a stock Fedora COSMIC, across 25
# packages, which rpm reports only in the transaction check, long after
# dnf's dependency solving has said the transaction is fine.
#
# The alternative to reducing the tree is to claim all 25 packages with
# Conflicts, which means erasing them to install this, which on Fedora
# includes cosmic-greeter -- the display manager. A fork you can only try
# by removing the desktop you would fall back to is a fork with no way
# back. So everything the distribution already owns is dropped, and the
# package depends on the distribution's COSMIC for it.
#
# Not on Debian, which has no COSMIC to depend on: neither cosmic-session
# nor cosmic-comp is packaged there, in any suite. Reducing the tree there
# would produce a package whose dependency can never be satisfied, so the
# Debian build keeps the whole desktop it just compiled and stands alone.
# Revisit when Debian packages COSMIC.
- name: Reduce the staged tree to this fork's own files
if: matrix.distro != 'debian'
run: |
set -eux
# /usr/share/cosmic goes too, all of it. Those are the defaults the
# compositor reads at first run, and this fork carries upstream's
# copies unmodified -- every one of them is byte-identical to a file a
# distribution package already owns. rpm permits two packages to own
# an identical file, so keeping them would install today and then
# collide the first time the distribution changed one. They arrive
# with the cosmic-comp this package depends on.
( cd stage && find . \( -type f -o -type l \) -printf '%P\n' ) |
while read -r p; do
case $p in
usr/bin/hyprcosmic-*|usr/bin/start-hyprcosmic) continue ;;
usr/share/hyprcosmic/*) continue ;;
usr/share/wayland-sessions/hyprcosmic.desktop) continue ;;
esac
rm -f "stage/$p"
done
find stage -type d -empty -delete
# Worth failing here rather than shipping a package that is missing the
# compositor. The negative assertions are the ones that would rot quietly:
# nothing may return to the private libexec layout this fork used to
# install into, and no cosmic-* name may come back, because either one is
# a file conflict that only shows up on a machine that has COSMIC
# installed -- which is every machine this is meant for.
- name: Assert the staged tree is this fork and nothing else
if: matrix.distro != 'debian'
run: |
set -eux
test -x stage/usr/bin/hyprcosmic-comp
test -x stage/usr/bin/hyprcosmic-session
test -x stage/usr/bin/hyprcosmic-conf
test -x stage/usr/bin/start-hyprcosmic
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
test -d stage/usr/share/hyprcosmic
test ! -e stage/usr/libexec/hyprcosmic
test ! -e stage/usr/bin/cosmic-comp
test ! -e stage/usr/bin/cosmic-session
test ! -e stage/usr/share/wayland-sessions/cosmic.desktop
test ! -e stage/usr/share/cosmic
test -z "$(find stage/usr/bin -mindepth 1 ! -name 'hyprcosmic-*' ! -name 'start-hyprcosmic')"
echo "staged files: $(find stage -type f | wc -l)"
# Debian is not reduced, so the assertion is the opposite one: the package
# stands alone there and has to carry a desktop that starts. The renamed
# three must be present under their new names, and the components the
# session launches must still be in the tree rather than assumed to arrive
# from a distribution package that does not exist.
- name: Assert the staged tree is a complete desktop
if: matrix.distro == 'debian'
run: |
set -eux
test -x stage/usr/bin/hyprcosmic-comp
test -x stage/usr/bin/hyprcosmic-session
test -x stage/usr/bin/hyprcosmic-conf
test -x stage/usr/bin/start-hyprcosmic
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
test -d stage/usr/share/hyprcosmic
test -d stage/usr/share/cosmic
test ! -e stage/usr/libexec/hyprcosmic
test ! -e stage/usr/bin/cosmic-comp
test ! -e stage/usr/bin/cosmic-session
test ! -e stage/usr/bin/start-cosmic
test ! -e stage/usr/share/wayland-sessions/cosmic.desktop
for c in cosmic-settings cosmic-settings-daemon cosmic-osd cosmic-notifications; do
test -x "stage/usr/bin/$c" || { echo "missing $c" >&2; exit 1; }
done
echo "staged files: $(find stage -type f | wc -l)"
# Checked here, once, rather than in each of the three packaging recipes,
# and not with desktop-file-validate.
#
# desktop-file-validate rejects DesktopNames -- "keys extending the format
# should start with X-" -- because the Desktop Entry Specification
# registers keys for application launchers, and these are session files.
# DesktopNames is what a display manager reads to set XDG_CURRENT_DESKTOP,
# so the session needs it. cosmic.desktop is upstream cosmic-session's
# file, unchanged here apart from the Exec path, and the copy Fedora ships
# as cosmic-session-1.5.0-1.fc44 fails the identical check: the validator
# has no entry for the key, and every distribution ships the file anyway.
#
# What the validator would not have caught is the failure that actually
# matters: an Exec naming a binary this package does not install puts an
# entry on the greeter's menu that dies silently when chosen. So that is
# what is checked, against the tree about to be packaged.
- name: Check the session entries
run: |
set -eu
for f in stage/usr/share/wayland-sessions/*.desktop; do
echo "== $f"
cat "$f"
test "$(sed -n 1p "$f")" = '[Desktop Entry]' || {
echo "$f: first line is not [Desktop Entry]" >&2; exit 1; }
for key in Name Type Exec DesktopNames; do
grep -q "^${key}=" "$f" || { echo "$f: no $key=" >&2; exit 1; }
done
grep -qx 'Type=Application' "$f" || {
echo "$f: Type is not Application; a greeter will ignore it" >&2; exit 1; }
exec_path=$(sed -n '0,/^Exec=/s/^Exec=//p' "$f" | cut -d' ' -f1)
case $exec_path in
/*) ;;
*) echo "$f: Exec=$exec_path is not absolute" >&2; exit 1 ;;
esac
test -x "stage${exec_path}" || {
echo "$f: Exec=$exec_path is not an executable this package installs" >&2
exit 1
}
echo " Exec -> stage${exec_path} ok"
done
# ---- 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.
# To a file and then head: `| head` would kill pacman with SIGPIPE
# once head has its 20 lines, and these steps run with pipefail.
pacman -Qlp dist/*.pkg.tar.zst > contents.txt
echo "entries: $(wc -l < contents.txt)"
head -20 contents.txt
# ---- 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
# To a file and then head, not `| head`. Actions runs these steps with
# pipefail, and head closing the pipe after 20 lines kills dpkg-deb
# with SIGPIPE, which pipefail reports as a failed step -- a green
# 217 MB package failed here on nothing but that.
dpkg-deb --contents dist/*.deb > contents.txt
echo "entries: $(wc -l < contents.txt)"
head -20 contents.txt
# 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 install beside the distribution's COSMIC
# rather than over it, so a bad one costs a logout rather than a
# desktop -- but a tag push is still not a decision to publish. The
# generated notes are written by a machine reading commit subjects,
# and the Debian package differs from the other two in what it
# carries; both are worth a human reading before anyone downloads.
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.
+2 -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
+324 -305
View File
@@ -1,346 +1,365 @@
# 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-monitor](https://github.com/pop-os/cosmic-monitor)
* [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-sound-theme](https://github.com/pop-os/cosmic-sound-theme)
* [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 installs next to COSMIC rather than over it.** The binaries are
`/usr/bin/hyprcosmic-comp`, `/usr/bin/hyprcosmic-session` and
`/usr/bin/hyprcosmic-conf`, and nothing here writes a path the distribution
owns. The stock COSMIC entry stays on the greeter's menu, served by the
distribution's own binaries, so the day the HyDE session does not start is one
logout away from a desktop that does. (On Debian, where COSMIC is not
packaged, the `.deb` carries the desktop itself — see [Installing](#installing).)
### 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/hyprcosmic-comp`, alongside upstream's two
`.ron` defaults files, which are carried unmodified. The distribution's
`cosmic-comp` is left where it is, for the stock session to keep using.
### 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
Nothing is removed and nothing conflicts. COSMIC is a dependency rather than a
casualty: the package installs `hyprcosmic-comp`, `hyprcosmic-session` and
`hyprcosmic-conf` beside the distribution's, and takes cosmic-settings, the
portal, the OSD and the rest from the distribution at the version it tested
them at. Log out and pick **HyprCosmic** from the greeter; pick **COSMIC** to go
back.
`sudo apt install cosmic-session`
An earlier revision did take the `cosmic-*` names, and it could not be
installed. Its files collided with 25 distribution packages, and the only way to
satisfy that was to erase them — including cosmic-greeter, which on a Fedora
COSMIC install *is* the display manager. A desktop you can only try by removing
the desktop you would fall back to is not one worth shipping.
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.
**Debian is the exception**, because COSMIC is not packaged there — no
`cosmic-session`, no `cosmic-comp`, in any suite. There is nothing to depend on
and nothing to install beside, so the `.deb` carries the whole desktop it
compiled and stands alone, and the greeter offers **HyprCosmic** only. The
Fedora and Arch packages ship this fork's three binaries and nothing else.
## 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.
Note that `just install` is not what the packages do. It writes upstream's whole
desktop at upstream's names, so run against `/usr` on a machine that has COSMIC
packaged it will overwrite files your package manager owns. The packages are
built from this same tree and then reduced to this fork's own files and renamed;
that step lives in `.github/workflows/packages.yml`, not in the justfile, which
is upstream's. Stage to a directory and inspect it, or install a package.
### Versioning
`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.
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).
Then log out. `HyprCosmic` appears on the greeter's session menu next to
`COSMIC`; both work.
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.)
### Per-user setup
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.
Nothing to do. The session seeds `~/.config` from `/usr/share/hyprcosmic/skel`
at login, copying only what is missing:
## Translating
```
~/.config/hyprcosmic/autostart
~/.config/hyprcosmic/cosmic.conf
~/.config/hyprcosmic/waybar/style.css
~/.config/hyprcosmic/waybar/theme.css
~/.config/rofi/config.rasi
~/.config/rofi/theme.rasi
~/.config/rofi/local.rasi
```
To submit translations for COSMIC in your language, please use Weblate: https://hosted.weblate.org/projects/pop-os/
An existing file is never touched, not even when the skeleton is newer — the
file wins, one way, and a login is not an invitation to edit your config. Two
things follow: your edits survive every login and upgrade, and deleting a file
is how you ask for the default back. `~/.cache/hyprcosmic/session.log` records
what was seeded.
## Contact
- [Mattermost](https://chat.pop-os.org/)
- [Twitter](https://twitter.com/pop_os_official)
- [Instagram](https://www.instagram.com/pop_os_official/)
The cost of that is real and worth stating: an upgrade that improves a shipped
default will not reach a file you already have. If a release changes something
you want — the `autostart` line that sets the wallpaper did change once — take
it deliberately, after reading what you would lose:
```shell
diff -u ~/.config/hyprcosmic/autostart /usr/share/hyprcosmic/skel/hyprcosmic/autostart
cp /usr/share/hyprcosmic/skel/hyprcosmic/autostart ~/.config/hyprcosmic/
```
`autostart` is the one that matters most, because it is what starts waybar, the
wallpaper daemon and `hyprcosmic-conf watch`. Until the session seeded it, a
machine that had never run HyprCosmic logged in to a bare compositor: running,
holding the display and accepting input, with nothing drawn on the screen and no
binding that opened anything.
`style.css` and `config.rasi` are per-user rather than shared for one reason:
each `@import`s a sibling holding the installed HyDE theme's palette, and a
relative `@import` resolves against the importing file. Those siblings —
`theme.css`, `theme.rasi` and `local.rasi` — arrive empty and are written by
`import-theme --assets`. They ship empty rather than not at all because a
missing `@import` is fatal to both consumers rather than a warning they skip:
GTK fails the entire stylesheet, and rofi reports the error in place of the
launcher.
Working from a git checkout, `just install` still places nothing in a home
directory — under `sudo` the only home directory it could see is root's — but it
does install the skeleton, so logging in seeds the same seven files.
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 glyphs the bar and the launcher draw with.
The wallpaper is `awww`'s job, and the image it draws is
`~/.local/share/wallpapers/hyprcosmic/current` — a symlink `import-theme
--assets` maintains. Before you have imported a theme there is no such link, so
`hyprcosmic-wallpaper` falls back to whatever the distribution ships,
`/usr/share/backgrounds/cosmic` first; the Fedora package recommends
`cosmic-wallpapers` so there is something there. With no `awww` installed it
says so on the session log and leaves the background alone, rather than waiting
for a daemon that is never coming.
The font is the one thing the packaging cannot do for you on Fedora, which has
no package that provides a Nerd Font at all: `texlive-inconsolata-nerd-font`
installs under `texmf-dist` and kitty's `SymbolsNerdFont` under
`/usr/lib64/kitty`, and fontconfig scans neither. Arch has
`ttf-nerd-fonts-symbols`. Otherwise, install one into your own font directory:
```shell
mkdir -p ~/.local/share/fonts/JetBrainsMonoNerdFont
# unpack JetBrainsMono.zip from github.com/ryanoasis/nerd-fonts/releases there
fc-cache -f
fc-list ":charset=e0b0" family | grep -i nerd # non-empty: the glyphs resolve
```
Query the charset on its own, as above. Adding a family filter —
`":charset=e0b0:family=JetBrainsMono Nerd Font"` — reports nothing even when the
font does cover the codepoint, because the family string is a comma-separated
alias list (`JetBrainsMono Nerd Font,JetBrainsMono NF`). Without the font the
bar still works; every icon is a tofu box.
## Configuration
`~/.config/hyprcosmic/cosmic.conf`, in Hyprland's idiom, compiled into
`cosmic-config` by:
```shell
hyprcosmic-conf apply # once
hyprcosmic-conf apply --diff # show what would change, write nothing
hyprcosmic-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
hyprcosmic-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).
+94
View File
@@ -0,0 +1,94 @@
# 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.
hyprcosmic-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.
#
# It sets ~/.local/share/wallpapers/hyprcosmic/current, a symlink
# `hyprcosmic-conf import-theme --assets` maintains beside the wallpapers it
# copies. The link is used rather than a real path so that this 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 either
# pointing at a file that no longer exists.
#
# Until a theme has been imported there is no such link, so on a machine that
# has just installed the package it falls back to whatever the distribution
# ships, /usr/share/backgrounds/cosmic first. A first login gets a desktop.
#
# 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
#
# A program rather than the `sh -c` this used to be, because waiting for the
# daemon needs a bound: awww is a Recommends in a COPR, and the old unbounded
# `until awww query` loop spun for the whole session on a machine without it.
hyprcosmic-wallpaper
# 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
+86
View File
@@ -0,0 +1,86 @@
#!/bin/sh
# Report fan speeds for waybar's custom/fan module, as JSON on stdout.
#
# waybar has no fan module, and its temperature module cannot be borrowed for
# this: it divides by 1000 to turn millidegrees into degrees, which would render
# 2700 rpm as 2 C.
#
# Nor can the sensor be named the way "temperature" names its own, with
# hwmon-path-abs. That option points at a *parent* directory and takes the one
# hwmonN inside it, which works for k10temp and amdgpu because each has exactly
# one. The asus platform device has two --
#
# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon9 name=asus
# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon10 name=asus_custom_fan_curve
#
# -- and only the first has fan*_input; the second holds the curve's set points.
# Which of the two waybar picked would be down to readdir order.
#
# So this resolves by content instead of by path: any hwmon with a fan*_input.
# That also makes it portable off this laptop, which a hardcoded asus path would
# not be. Fans on a desktop's it87 or a thinkpad's thinkpad_hwmon are found the
# same way.
#
# Prints nothing at all when the machine has no readable fan -- a VM, or a
# passively cooled box. waybar renders an empty custom module as nothing, so the
# bar loses the item rather than showing a dead zero.
set -eu
max=0
tooltip=''
for hwmon in /sys/class/hwmon/hwmon*; do
[ -d "$hwmon" ] || continue
# Not every hwmon has a name, and a chip is worth naming in the tooltip
# when it does: "cpu_fan" alone does not say which controller reported it.
chip=$(cat "$hwmon/name" 2>/dev/null) || chip=''
[ -n "$chip" ] || chip=$(basename "$hwmon")
for input in "$hwmon"/fan*_input; do
# The glob is literal when nothing matches, which is the common case:
# most hwmons here are temperature-only.
[ -e "$input" ] || continue
rpm=$(cat "$input" 2>/dev/null) || continue
# A fan that is stopped reads 0, and a fan that has been unbound reads
# nothing. Neither is an error, but neither belongs in the tooltip.
case $rpm in
'' | *[!0-9]*) continue ;;
esac
# fanN_label when the driver supplies one -- "cpu_fan", "gpu_fan" --
# and fanN otherwise.
label=$(cat "${input%_input}_label" 2>/dev/null) || label=''
[ -n "$label" ] || label=$(basename "${input%_input}")
[ "$rpm" -gt "$max" ] && max=$rpm
line="$chip $label: $rpm rpm"
if [ -n "$tooltip" ]; then
tooltip="$tooltip\\n$line"
else
tooltip=$line
fi
done
done
# No fan anywhere. Say nothing rather than reporting a confident 0 rpm.
[ -n "$tooltip" ] || exit 0
# The bar shows the fastest fan, because that is the one you can hear and the
# one that says whether the machine is working. The rest are in the tooltip.
#
# The class drives the colour in rules.css. 4000 rpm is where this laptop's fans
# become audible over a quiet room; below 1 rpm every fan is stopped, which is
# worth showing differently from a slow one.
if [ "$max" -ge 4000 ]; then
class=high
elif [ "$max" -eq 0 ]; then
class=idle
else
class=normal
fi
printf '{"text":"%s","tooltip":"%s","class":"%s"}\n' "$max" "$tooltip" "$class"
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/bash
#
# Show every keyboard shortcut in the session, and what each one runs.
#
# WHY THIS EXISTS
# ---------------
# COSMIC has no keyboard reference. cosmic-settings lists shortcuts in a
# scrolling pane behind three clicks, which is where you go to *change* one, not
# where you go to remember one mid-task. Hyprland desktops all ship a cheatsheet
# on a key and a bar click for exactly that reason, and this is that.
#
# WHERE THE BINDINGS COME FROM, AND WHY NOT cosmic.conf
# -----------------------------------------------------
# The obvious implementation reads cosmic.conf, and it would be wrong. This
# machine's cosmic.conf declares six bindings. The session answers to 122. The
# other 116 are COSMIC's own defaults, which the fork does not restate because
# it has no reason to -- Super+Q closes a window whether or not anybody wrote it
# down. A cheatsheet showing six entries would not look broken; it would look
# complete, and it would be missing every window, workspace and media key on the
# machine. That is a worse failure than not having the feature.
#
# So the sources are the three RON files the compositor actually reads:
#
# /usr/share/cosmic/.../Shortcuts/v1/defaults 116 COSMIC built-ins
# ~/.config/cosmic/.../Shortcuts/v1/custom 6 ours, projected from
# cosmic.conf by cosmic-conf
# /usr/share/cosmic/.../Shortcuts/v1/system_actions
# what System(X) actually runs
#
# custom wins over defaults on a collision, matching the order the compositor
# merges them, so a rebound key is listed once with the binding in force.
#
# system_actions is what makes this answer the question that was asked. A
# default reads `System(Screenshot)`, which names an action and not a program;
# system_actions maps that to `cosmic-screenshot`. Without it a third of the
# list would name internal actions rather than the applications they launch.
#
# THE PARSER
# ----------
# These are RON files, and this reads them with a regex, which deserves a
# defence. The alternative is a `cosmic-conf binds` subcommand, which would be
# the principled home for this -- cosmic-conf already has the schema and the
# bind grammar. That is a compiled change and a full package build, and this
# feature does not need one: the files are one binding per line, and the shape
# has not moved. The cost of being wrong here is a mangled row in a reference
# window, not a broken keybinding, because nothing downstream consumes this.
#
# If the format does move, the failure is visible immediately and loudly: the
# count check below refuses to show a list it could not parse.
set -uo pipefail
SYS_DIR=/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1
USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/cosmic/com.system76.CosmicSettings.Shortcuts/v1"
command -v rofi >/dev/null 2>&1 || {
printf 'hyprcosmic-keybinds: rofi is not installed\n' >&2
exit 1
}
# Errors go to a rofi dialog as well as stderr: the two ways in are a keybinding
# and a bar click, and neither has a terminal to read stderr from. Same reasoning
# as hyprcosmic-powermenu.
fail() {
printf 'hyprcosmic-keybinds: %s\n' "$*" >&2
rofi -e "$*" >/dev/null 2>&1 || :
exit 1
}
[[ -r "$SYS_DIR/defaults" ]] ||
fail "cannot read $SYS_DIR/defaults, so the built-in shortcuts are unknown"
# Resolve System(X) to the command it runs, as "X<tab>command" pairs.
#
# system_actions carries /// doc comments and commented-out entries for actions
# that exist in the enum but are not wired up (KeyboardBrightnessUp is one), so
# this matches only lines that are a bare name followed by a quoted string.
system_actions() {
[[ -r "$SYS_DIR/system_actions" ]] || return 0
sed -nE 's/^[[:space:]]*([A-Za-z]+):[[:space:]]*"(.*)",?[[:space:]]*$/\1\t\2/p' \
"$SYS_DIR/system_actions"
}
# One binding per line, as "modifiers<tab>key<tab>action".
#
# Two shapes appear. The usual one carries a key:
# (modifiers: [Super, Shift], key: "Escape"): System(LogOut),
# and a modifier-only binding has none at all:
# (modifiers: [Super]): Spawn("rofi -show drun"),
# The second is how Super alone opens the launcher, so dropping it would lose
# the single most used binding on the machine.
bindings() {
sed -nE \
-e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\],[[:space:]]*key:[[:space:]]*"([^"]*)"\):[[:space:]]*(.*),[[:space:]]*$/\1\t\2\t\3/p' \
-e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\]\):[[:space:]]*(.*),[[:space:]]*$/\1\t\t\2/p' \
"$1"
}
actions_tsv="$(system_actions)"
# custom is read second so that its rows overwrite defaults in the map below.
# Missing is normal, not an error: a session with no custom bindings has no such
# file, and every default still applies.
raw="$(bindings "$SYS_DIR/defaults")"
if [[ -r "$USER_DIR/custom" ]]; then
raw+=$'\n'"$(bindings "$USER_DIR/custom")"
fi
[[ -n "${raw//[[:space:]]/}" ]] ||
fail "parsed no shortcuts at all from $SYS_DIR/defaults; the file format has changed"
rendered="$(
LC_ALL=C awk -F'\t' -v actions="$actions_tsv" '
BEGIN {
# System(X) -> command, from system_actions.
n = split(actions, lines, "\n")
for (i = 1; i <= n; i++) {
if (split(lines[i], kv, "\t") == 2) cmd[kv[1]] = kv[2]
}
}
# Turn CamelCase into words so an action reads as a description rather than
# an identifier: MoveToWorkspace -> "move to workspace". Applied only to the
# action name, never to a command, which must stay verbatim to be typed.
function words(s, out) {
out = s
gsub(/([a-z0-9])([A-Z])/, "\\1 \\2", out)
return tolower(out)
}
# Super is written first and Shift last, so that chords sort and read the
# way they are pressed rather than the way the RON happened to list them.
function order(m) {
if (m == "Super") return 1
if (m == "Ctrl") return 2
if (m == "Alt") return 3
if (m == "Shift") return 4
return 5
}
{
mods = $1; key = $2; action = $3
if (action == "") next
# "Super, Alt" -> "Super + Alt", in a fixed order.
cnt = split(mods, m, /,[[:space:]]*/)
for (i = 1; i <= cnt; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", m[i])
for (i = 1; i < cnt; i++)
for (j = i + 1; j <= cnt; j++)
if (order(m[j]) < order(m[i])) { t = m[i]; m[i] = m[j]; m[j] = t }
chord = ""
for (i = 1; i <= cnt; i++) chord = chord (chord == "" ? "" : " + ") m[i]
if (key != "") chord = chord (chord == "" ? "" : " + ") key
# Spawn("cmd") is a command as written in cosmic.conf.
# System(X) is an action name that system_actions turns into a command.
# Everything else is internal to the compositor and has no command; the
# action itself is the honest answer, and inventing a program name for
# Focus(Left) would be a lie.
what = ""
if (action ~ /^Spawn\(".*"\)$/) {
what = substr(action, 8, length(action) - 9)
} else if (action ~ /^System\(/) {
name = action
sub(/^System\(/, "", name); sub(/\)$/, "", name)
what = (name in cmd) ? cmd[name] : words(name)
} else if (action ~ /\(/) {
name = action; arg = action
sub(/\(.*$/, "", name)
sub(/^[^(]*\(/, "", arg); sub(/\)$/, "", arg)
what = words(name) " " arg
} else {
what = words(action)
}
# Last write wins, which is why custom is appended after defaults.
seen[chord] = what
if (!(chord in orderkey)) orderkey[chord] = ++seq
}
END {
for (c in seen) printf "%d\t%s\t%s\n", orderkey[c], c, seen[c]
}
' <<<"$raw" | sort -n | cut -f2-
)"
count="$(grep -c . <<<"$rendered")"
((count > 0)) || fail "every shortcut line failed to parse; the RON format has changed"
# Column-aligned so the chords form a readable left edge. Done here rather than
# in awk because the width has to be measured across the whole set first.
width="$(cut -f1 <<<"$rendered" | LC_ALL=C awk '{ if (length($0) > w) w = length($0) } END { print w }')"
# -dmenu rather than a static window, so the list is filterable: 122 bindings is
# more than fits on a screen, and typing "workspace" is how you actually use
# this. No theme arguments, so ~/.config/rofi/config.rasi applies and it matches
# the launcher and the power menu.
#
# The selection is discarded. This is a reference, not a menu -- there is no
# sensible action for "you picked Super + Q", and running the bound command on
# Return would make an accidental keypress close a window from a help screen.
LC_ALL=C awk -F'\t' -v w="$width" '{ printf "%-*s %s\n", w, $1, $2 }' <<<"$rendered" |
rofi -dmenu -i -no-custom -no-show-icons -p "keys" \
-mesg "$count shortcuts" >/dev/null 2>&1 || :
+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
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
# Set the desktop wallpaper once the wallpaper daemon is listening.
#
# The hyprcosmic profile does not start cosmic-bg, so nothing else draws a
# background: without this there is a black screen below the bar, and no error
# anywhere, because the failure is that nobody asked for a wallpaper.
#
# This was three shell fragments on one autostart line. It became a program for
# two reasons, both of which cost a first login its desktop:
#
# - `current` is a symlink `import-theme --assets` maintains, so it does not
# exist until a theme has been imported. Naming it directly meant a machine
# that had just installed the package set no wallpaper at all.
# - The wait for the daemon was `until awww query; do sleep 0.2; done`, with
# nothing to stop it. awww is a Recommends and lives in a COPR, so on a
# machine that never enabled that repository the loop spun at 5 Hz for the
# length of the session.
#
# Both are now bounded and both say so on the session log.
set -eu
link="${XDG_DATA_HOME:-$HOME/.local/share}/wallpapers/hyprcosmic/current"
# Searched in order when the link is not there yet. The first is cosmic-wallpapers,
# which the Fedora package recommends for exactly this; the other two are where
# freedesktop and KDE put theirs, so a machine with neither still has a chance.
fallbacks='/usr/share/backgrounds/cosmic /usr/share/backgrounds /usr/share/wallpapers'
if ! command -v awww >/dev/null 2>&1; then
echo "hyprcosmic-wallpaper: awww is not installed; no wallpaper set" >&2
exit 0
fi
# awww-daemon is started immediately above this in autostart and has only just
# been forked, so an immediate `awww img` loses the race and fails silently.
# Ten seconds is far longer than the daemon has ever taken and short enough that
# a daemon which is never coming does not leave a process spinning all session.
tries=0
until awww query >/dev/null 2>&1; do
tries=$((tries + 1))
if [ "$tries" -ge 50 ]; then
echo "hyprcosmic-wallpaper: awww-daemon did not answer in 10s; no wallpaper set" >&2
exit 0
fi
sleep 0.2
done
# -e rather than -L, deliberately: it follows the link, so a `current` left
# dangling by a deleted theme falls through to the defaults instead of being
# handed to awww as a path that is not there.
img=''
if [ -e "$link" ]; then
img=$link
else
for dir in $fallbacks; do
[ -d "$dir" ] || continue
for candidate in "$dir"/*.jpg "$dir"/*.jpeg "$dir"/*.png; do
# The glob is literal when nothing matches, which is why
# this tests the file rather than trusting the expansion.
[ -f "$candidate" ] || continue
img=$candidate
break
done
# An if, not `[ -n "$img" ] && break`: under set -e a trailing
# false at the end of the loop body would take the script with it.
if [ -n "$img" ]; then
break
fi
done
if [ -n "$img" ]; then
echo "hyprcosmic-wallpaper: no $link yet, falling back to $img" >&2
fi
fi
if [ -z "$img" ]; then
echo "hyprcosmic-wallpaper: no wallpaper found in $link or $fallbacks" >&2
exit 0
fi
exec awww img "$img"
+206
View File
@@ -0,0 +1,206 @@
# 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
}
# --- Workspaces ----------------------------------------------------------
#
# COSMIC's workspaces are dynamic and there is no setting that turns that off:
# it keeps exactly one trailing empty workspace and collects every other empty
# one the moment its last window closes. So "workspace 4 is the browser" is only
# true while the browser is open, and the number moves under you as workspaces
# come and go.
#
# These lines declare a fixed set instead, which is what a Hyprland user expects.
# A declared workspace is pinned, and pinning is precisely what exempts it from
# that collection, so it stays at its number whether or not anything is on it.
#
# The dynamic workspace is not lost. Declaring four leaves you with 1-4 always
# present and a fifth appearing the moment you use it, then a sixth after that,
# exactly as Hyprland does.
#
# Two things worth knowing before you edit:
#
# * The index is required and workspaces below it are created too, because
# COSMIC restores them by position rather than by number. `workspace = 4`
# on its own gives you four workspaces, three of them unnamed.
# * This is the one key in this file that is not live. The compositor reads
# it once at startup, so an edit applies at your next login. Everything
# else here takes effect as soon as you save.
#
# `name:` is what waybar and anything else reading ext-workspace will show.
# `tiling:` overrides `general:autotile` for that workspace alone; leave it out
# and the workspace follows the setting above. Hyprland's `monitor:` is not
# accepted -- COSMIC matches a workspace to a monitor by EDID rather than by
# name, and there is no way to write an EDID down here. Saying so is better
# than accepting the parameter and quietly ignoring it.
#
# Commented out because a fixed set is a preference, not an improvement, and
# leaving these off gives you COSMIC's stock behaviour. Uncomment to opt in.
#
# workspace = 1, name:term
# workspace = 2, name:web
# workspace = 3, name:code
# workspace = 4, name:chat
# --- Window rules --------------------------------------------------------
#
# Where an application opens, decided from what it is rather than from where
# you happened to be standing. `windowrule = workspace 4, class:^(vivaldi)$`
# means the browser lands on workspace 4 no matter which one is in front of you.
#
# Rules are matched once, as the window opens, and the first one that matches
# wins -- so put the specific ones above the general ones. `class:` and `title:`
# are regular expressions; give both and both have to match. A rule with neither
# is refused, since it would match every window in the session.
#
# These pair with the workspace lines above. `workspace name:web` is worth
# preferring over `workspace 2`: a name follows the workspace if you renumber
# it, and only exists because you declared it, so a typo fails loudly rather
# than sending the window to whatever happens to be second.
#
# A rule never switches you to the workspace it used -- Hyprland's `silent`,
# always on. The word is accepted so pasted-in configs keep working.
#
# Only `workspace` is supported. Hyprland's float, size, move, opacity and the
# rest have nothing in COSMIC to project onto, and matchers that ask about
# window state -- floating:, fullscreen:, onworkspace: -- cannot be answered at
# the moment a window opens. Both fail with an explanation rather than parsing
# and doing nothing. Floating for a particular application is a tiling
# exception, which lives in COSMIC's own settings rather than in this file.
#
# Unlike the workspace lines, these are live: save the file and the next window
# to open obeys them.
#
# windowrule = workspace name:web, class:^(vivaldi|firefox)$
# windowrule = workspace name:code, class:^(codium|code)$
# windowrule = workspace name:chat, class:^(discord|Element)$
# windowrule = workspace 1, class:^(kitty)$, title:^(dev)$
# --- 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
# --- Help ---------------------------------------------------------------
#
# Every shortcut in the session, in one searchable window, with the command
# each one runs. cosmic-settings can show these, but it is where you go to
# change a binding, not where you go to remember one -- and the hyprcosmic
# profile does not put it one click away.
#
# The script reads COSMIC's own Shortcuts files rather than this one. That is
# deliberate and it matters: this file declares six bindings and the session
# answers to 122, the rest being COSMIC defaults there is no reason to restate
# here. A reference built from this file alone would look complete and be
# missing every window, workspace and media key on the machine.
#
# Super+Shift+/ is Super+? on this keyboard, which is the usual spelling for
# help, and it is one of the few chords in that corner COSMIC leaves free --
# Super+K and Super+I are both focus actions in the defaults. The same script
# backs waybar's keyboard button.
bind = $mainMod SHIFT, slash, exec, hyprcosmic-keybinds
+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"
+29
View File
@@ -0,0 +1,29 @@
/* Per-machine launcher settings for HyprCosmic.
*
* Empty until `hyprcosmic-conf import-theme --assets` writes it. It exists from
* the first session because config.rasi imports it, and a missing @import is an
* error rofi reports in place of the launcher rather than a warning it skips.
*
* It is the last of the four imports in config.rasi, so anything here wins; it
* is also overwritten by the next import run with --overwrite, so keep hand
* edits somewhere else.
*
* Two things belong in this file and nothing else does: values that name a path
* or a package on this particular machine, which neither /usr/share/hyprcosmic
* nor a HyDE theme file can know. Concretely, that is the icon theme and the
* sidebar image:
*
* configuration {
* icon-theme: "Tela-circle-purple", "Adwaita";
* }
*
* dummywall {
* background-image: url("/home/you/.local/share/wallpapers/hyprcosmic/current", height);
* }
*
* Both are shown commented out rather than set, because a default that named
* an icon theme nobody has installed, or a wallpaper path under someone else's
* home, would be worse than no default at all.
*/
* {}
+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;
}
+16
View File
@@ -0,0 +1,16 @@
/* Step 2 of the import chain in config.rasi: the installed HyDE theme's rofi
* colours.
*
* Empty on purpose, for the same reason as waybar's theme.css: a missing
* @import is an error rofi reports in place of the launcher rather than a
* warning it skips, so config.rasi can only import a file that is guaranteed
* to exist. palette.rasi already defines every name rules.rasi reads, so
* defining nothing here leaves a complete theme rather than an unresolved
* reference.
*
* `hyprcosmic-conf import-theme` overwrites this with the imported theme's
* rofi colours. Hand-written overrides belong in local.rasi, which is imported
* after the rules and is left alone unless --overwrite is passed.
*/
* {}
+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);
+427
View File
@@ -0,0 +1,427 @@
// 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": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
"modules-center": ["mpris", "clock", "privacy"],
"modules-right": [
"systemd-failed-units",
"idle_inhibitor",
"custom/swaync",
"bluetooth",
"pulseaudio",
"network",
"disk",
"temperature",
"temperature#gpu",
"custom/fan",
"cpu",
"memory",
"power-profiles-daemon",
"battery",
"tray",
"custom/power"
],
// Opens the keyboard reference. First on the bar because it is the thing
// you look for when you do not yet know where anything is.
//
// No "exec": this is a button, not a readout. A custom module with only a
// format and an on-click runs nothing on an interval, so it costs a static
// glyph and nothing else.
//
// The same script is on Super + Shift + / -- the binding is named in the
// tooltip because a help button you have to reach for with the mouse has
// not finished helping.
"custom/keybinds": {
"format": "\udb80\udf0c",
"tooltip": true,
"tooltip-format": "keyboard shortcuts\nSuper + Shift + /",
"on-click": "hyprcosmic-keybinds"
},
"ext/workspaces": {
"format": "{name}",
"on-click": "activate"
},
"wlr/taskbar": {
"format": "{icon}",
"icon-size": 18,
"tooltip-format": "{title}",
"on-click": "activate",
"on-click-middle": "close"
},
// The taskbar above shows which windows exist; this shows which one has the
// keyboard. Nothing else on the bar answered that.
//
// This is the one module here that talks to the fork's Hyprland IPC rather
// than to a Wayland protocol, and it needs no compositor change: waybar
// reads `j/activewindow` on startup and then follows `activewindow>>` on
// .socket2.sock, both of which hypr_ipc already serves. A retitle of the
// focused window propagates too -- src/hypr_ipc/sync.rs snapshots the
// focused window as (class, title) and emits on any difference, which is
// why upstream's separate `windowtitle` event is not needed here. Upstream
// Hyprland emits from call sites and so must announce that case
// separately; this fork diffs state on a 150 ms tick and cannot miss it.
//
// `separate-outputs` is left off deliberately: there is one bar on one
// output, and with it on, the module reports that monitor's active window
// rather than the focused one, which on a single head is the same answer by
// a longer route.
"hyprland/window": {
"format": "\uf2d0 {title}",
"max-length": 60,
"tooltip": true,
"tooltip-format": "{title}\n\nclass: {class}",
// Titles are written for a window's own title bar, not for a 60-column
// slot on a bar, so the worst offenders are trimmed to the part that
// identifies the window. ECMAScript regex; the value is a replacement
// string where $1 is the first capture.
//
// The separator is an em dash, and it is written as an escape
// for the same reason every glyph in this file is: the template is read
// as ASCII and the generator refuses to emit anything that is not, so a
// literal em dash pasted here fails the build rather than reaching the
// bar. COSMIC's applications really do use an em dash and not a hyphen
// -- `j/clients` reports "dingo@fedora:~ <em dash> COSMIC Terminal" -- so
// a rule written with a hyphen matches nothing.
//
// There is deliberately no catch-all for the empty title. waybar's
// documentation does not say whether a rule is applied on a full match
// or a search, so an empty pattern is either "matches nothing but the
// empty title" or "matches every title", and the difference is the
// whole bar. The no-window case is handled in rules.css instead, the
// same way #mpris already handles having nothing to say.
"rewrite": {
"(.*) [-\u2014] Vivaldi": "$1",
"(.*) [-\u2014] COSMIC Terminal": "$1",
"(.*) [-\u2014] COSMIC Text Editor": "$1",
"(.*) [-\u2014] COSMIC Files": "$1",
"(.*) [-\u2014] Mozilla Firefox": "$1"
}
},
// 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 }
]
},
// First on the right, and silent unless something is wrong. A unit that
// failed at boot otherwise stays failed until the day you happen to run
// `systemctl --failed` -- there is no other surface for it in a COSMIC
// session, which has no equivalent of GNOME's abrt notification.
//
// Both scopes are watched. The user scope is where a session's own units
// fail, which is the half that matters here and the half a system-only
// check misses.
"systemd-failed-units": {
"format": "\udb80\udc26 {nr_failed}",
"format-ok": "",
"system": true,
"user": true,
"hide-on-ok": true,
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
},
"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"
},
// Root only. /home is on the same filesystem here, and a second entry for
// it would be the same number twice.
//
// No warning threshold is set: waybar's disk module has no `states`, so the
// colour comes from rules.css, which cannot see the percentage. The number
// is the warning.
"disk": {
"path": "/",
"format": "\udb80\udeca {percentage_used}%",
"interval": 60,
"tooltip-format": "{used} used of {total} on {path}, {free} free"
},
// 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"
},
// A second instance of the same module. `temperature#gpu` is waybar's alias
// syntax: everything before the # selects the module, everything after is
// just a name, and it becomes #temperature.gpu in CSS.
//
// The dGPU's own sensor, not the CPU's. On this machine the two diverge by
// 30 degrees under load, so one reading cannot stand for both.
//
// temp1_label reads "edge" -- the die edge, which is the sensor amdgpu
// exposes on this card. There is no junction or memory sensor here to
// prefer over it.
//
// hwmon-path-abs is safe for this one: the PCI device has exactly one
// hwmonN inside it, unlike the asus platform device that custom/fan below
// has to work around.
"temperature#gpu": {
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon",
"input-filename": "temp1_input",
"format": "\udb82\udcae {temperatureC} C",
"critical-threshold": 95,
"interval": 5,
"tooltip-format": "GPU edge: {temperatureC} C"
},
// Speed of the fastest fan, with every fan in the tooltip. See the script
// for why this is not a temperature module with a different divisor and not
// an hwmon-path-abs: both were tried and neither can name this sensor.
//
// Emits nothing on a machine with no readable fan, and waybar draws an
// empty custom module as nothing, so this costs no space on hardware that
// has none.
"custom/fan": {
"exec": "hyprcosmic-fan",
"return-type": "json",
"format": "\udb80\ude10 {} rpm",
"interval": 5
},
"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"
}
}
+427
View File
@@ -0,0 +1,427 @@
// 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": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
"modules-center": ["mpris", "clock", "privacy"],
"modules-right": [
"systemd-failed-units",
"idle_inhibitor",
"custom/swaync",
"bluetooth",
"pulseaudio",
"network",
"disk",
"temperature",
"temperature#gpu",
"custom/fan",
"cpu",
"memory",
"power-profiles-daemon",
"battery",
"tray",
"custom/power"
],
// Opens the keyboard reference. First on the bar because it is the thing
// you look for when you do not yet know where anything is.
//
// No "exec": this is a button, not a readout. A custom module with only a
// format and an on-click runs nothing on an interval, so it costs a static
// glyph and nothing else.
//
// The same script is on Super + Shift + / -- the binding is named in the
// tooltip because a help button you have to reach for with the mouse has
// not finished helping.
"custom/keybinds": {
"format": "@@KEYBOARD@@",
"tooltip": true,
"tooltip-format": "keyboard shortcuts\nSuper + Shift + /",
"on-click": "hyprcosmic-keybinds"
},
"ext/workspaces": {
"format": "{name}",
"on-click": "activate"
},
"wlr/taskbar": {
"format": "{icon}",
"icon-size": 18,
"tooltip-format": "{title}",
"on-click": "activate",
"on-click-middle": "close"
},
// The taskbar above shows which windows exist; this shows which one has the
// keyboard. Nothing else on the bar answered that.
//
// This is the one module here that talks to the fork's Hyprland IPC rather
// than to a Wayland protocol, and it needs no compositor change: waybar
// reads `j/activewindow` on startup and then follows `activewindow>>` on
// .socket2.sock, both of which hypr_ipc already serves. A retitle of the
// focused window propagates too -- src/hypr_ipc/sync.rs snapshots the
// focused window as (class, title) and emits on any difference, which is
// why upstream's separate `windowtitle` event is not needed here. Upstream
// Hyprland emits from call sites and so must announce that case
// separately; this fork diffs state on a 150 ms tick and cannot miss it.
//
// `separate-outputs` is left off deliberately: there is one bar on one
// output, and with it on, the module reports that monitor's active window
// rather than the focused one, which on a single head is the same answer by
// a longer route.
"hyprland/window": {
"format": "@@WINDOW@@ {title}",
"max-length": 60,
"tooltip": true,
"tooltip-format": "{title}\n\nclass: {class}",
// Titles are written for a window's own title bar, not for a 60-column
// slot on a bar, so the worst offenders are trimmed to the part that
// identifies the window. ECMAScript regex; the value is a replacement
// string where $1 is the first capture.
//
// The separator is an em dash, and it is written as an escape
// for the same reason every glyph in this file is: the template is read
// as ASCII and the generator refuses to emit anything that is not, so a
// literal em dash pasted here fails the build rather than reaching the
// bar. COSMIC's applications really do use an em dash and not a hyphen
// -- `j/clients` reports "dingo@fedora:~ <em dash> COSMIC Terminal" -- so
// a rule written with a hyphen matches nothing.
//
// There is deliberately no catch-all for the empty title. waybar's
// documentation does not say whether a rule is applied on a full match
// or a search, so an empty pattern is either "matches nothing but the
// empty title" or "matches every title", and the difference is the
// whole bar. The no-window case is handled in rules.css instead, the
// same way #mpris already handles having nothing to say.
"rewrite": {
"(.*) [-\u2014] Vivaldi": "$1",
"(.*) [-\u2014] COSMIC Terminal": "$1",
"(.*) [-\u2014] COSMIC Text Editor": "$1",
"(.*) [-\u2014] COSMIC Files": "$1",
"(.*) [-\u2014] Mozilla Firefox": "$1"
}
},
// 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 }
]
},
// First on the right, and silent unless something is wrong. A unit that
// failed at boot otherwise stays failed until the day you happen to run
// `systemctl --failed` -- there is no other surface for it in a COSMIC
// session, which has no equivalent of GNOME's abrt notification.
//
// Both scopes are watched. The user scope is where a session's own units
// fail, which is the half that matters here and the half a system-only
// check misses.
"systemd-failed-units": {
"format": "@@ALERT@@ {nr_failed}",
"format-ok": "",
"system": true,
"user": true,
"hide-on-ok": true,
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
},
"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"
},
// Root only. /home is on the same filesystem here, and a second entry for
// it would be the same number twice.
//
// No warning threshold is set: waybar's disk module has no `states`, so the
// colour comes from rules.css, which cannot see the percentage. The number
// is the warning.
"disk": {
"path": "/",
"format": "@@DISK@@ {percentage_used}%",
"interval": 60,
"tooltip-format": "{used} used of {total} on {path}, {free} free"
},
// 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"
},
// A second instance of the same module. `temperature#gpu` is waybar's alias
// syntax: everything before the # selects the module, everything after is
// just a name, and it becomes #temperature.gpu in CSS.
//
// The dGPU's own sensor, not the CPU's. On this machine the two diverge by
// 30 degrees under load, so one reading cannot stand for both.
//
// temp1_label reads "edge" -- the die edge, which is the sensor amdgpu
// exposes on this card. There is no junction or memory sensor here to
// prefer over it.
//
// hwmon-path-abs is safe for this one: the PCI device has exactly one
// hwmonN inside it, unlike the asus platform device that custom/fan below
// has to work around.
"temperature#gpu": {
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon",
"input-filename": "temp1_input",
"format": "@@GPU@@ {temperatureC} C",
"critical-threshold": 95,
"interval": 5,
"tooltip-format": "GPU edge: {temperatureC} C"
},
// Speed of the fastest fan, with every fan in the tooltip. See the script
// for why this is not a temperature module with a different divisor and not
// an hwmon-path-abs: both were tried and neither can name this sensor.
//
// Emits nothing on a machine with no readable fan, and waybar draws an
// empty custom module as nothing, so this costs no space on hardware that
// has none.
"custom/fan": {
"exec": "hyprcosmic-fan",
"return-type": "json",
"format": "@@FAN@@ {} rpm",
"interval": 5
},
"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"
}
}
+103
View File
@@ -0,0 +1,103 @@
#!/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,
"WINDOW": 0xF2D0,
"ALERT": 0xF0026,
"DISK": 0xF02CA,
"GPU": 0xF08AE,
"FAN": 0xF0210,
"KEYBOARD": 0xF030C,
}
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);
+189
View File
@@ -0,0 +1,189 @@
/* 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. */
#custom-keybinds,
#window,
#mpris,
#clock,
#privacy,
#systemd-failed-units,
#idle_inhibitor,
#custom-swaync,
#bluetooth,
#pulseaudio,
#network,
#disk,
#temperature,
#custom-fan,
#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;
}
/* The other button on the bar. Accent rather than critical, because this one is
* safe to press. */
#custom-keybinds:hover {
color: @accent;
}
#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;
}
/* Same reasoning as mpris, but the class is not on the widget. waybar puts
* `empty` on the whole bar rather than on #window, so the selector has to
* descend into it -- waybar-hyprland-window(5) gives it as exactly this:
*
* window#waybar.empty #window When no windows are in the workspace
*
* The first `window` is the GTK window, the second is our module; they are
* unrelated names that collide, which is why this reads so oddly. */
window#waybar.empty #window {
padding: 0;
background: transparent;
}
/* 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; }
/* No state class needed: hide-on-ok removes the module entirely at zero, so it
* is on screen only when there is something to report and can be coloured
* unconditionally. */
#systemd-failed-units { color: @critical; }
/* Set by hyprcosmic-fan: `high` above 4000 rpm, `idle` when every fan has
* stopped. `normal` is left alone. */
#custom-fan.high { color: @warning; }
#custom-fan.idle { color: @muted; }
#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");
+18
View File
@@ -0,0 +1,18 @@
/* Step 2 of the import chain in style.css: the installed HyDE theme's
* waybar.theme.
*
* This copy is deliberately empty of rules. It is not a stub waiting to be
* written -- an empty file is the correct content when no theme has been
* imported, and it is what makes the other three steps work:
*
* - A missing @import is fatal in GTK, so style.css cannot name a file that
* might not exist. This one always exists, from the moment the session
* first starts.
* - Every colour the rules consume already has a default from palette.css
* and bridge-hyde.css, so a file that defines nothing leaves a complete
* stylesheet rather than an undefined name.
*
* `hyprcosmic-conf import-theme` overwrites this with the waybar.theme of the
* theme being imported. Anything written here by hand is lost at that point,
* which is why the per-user override belongs in style.css instead.
*/
+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
+526
View File
@@ -0,0 +1,526 @@
# 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 = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[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",
"regex",
"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 = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[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 = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[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",
]
+27
View File
@@ -0,0 +1,27 @@
[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"
# Only to reject a bad `windowrule` expression at apply time rather than
# letting it fail silently in the compositor. Pinned to the same major the
# compositor matches with, so what compiles here compiles there.
regex = "1"
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 `hyprcosmic-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 `hyprcosmic-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);
}
}
+107
View File
@@ -0,0 +1,107 @@
//! 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 mod windowrule;
pub mod workspace;
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};
pub use windowrule::{parse_window_rule, WindowRuleDecl};
pub use workspace::{parse_workspace, WorkspaceDecl};
/// 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}");
}
}
+436
View File
@@ -0,0 +1,436 @@
//! `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};
// Names the installed binary, hyprcosmic-conf, not the crate. The package
// installs beside COSMIC and keeps every path it owns under a hyprcosmic-*
// name, so a usage line saying `cosmic-conf` would name something that is not
// on the system.
const USAGE: &str = "\
hyprcosmic-conf — compile cosmic.conf into the cosmic-config tree
USAGE:
hyprcosmic-conf apply [--diff] [--config <path>]
hyprcosmic-conf watch [--config <path>]
hyprcosmic-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);
}
}
File diff suppressed because it is too large Load Diff
+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]));
}
}
+532
View File
@@ -0,0 +1,532 @@
//! Hyprland `windowrule` lines -> COSMIC window rules.
//!
//! Hyprland's `windowrule` is a large surface: an action, a match, and around
//! forty possible actions ranging from `float` to `bordercolor`. Exactly one of
//! them is implemented here, `workspace`, because it is the one with a real
//! COSMIC counterpart -- a window can be mapped onto a workspace other than the
//! active one, which is what `windowrule = workspace 4, class:...` means.
//!
//! Everything else is refused with an explanation rather than accepted and
//! dropped. A rule that parses and then does nothing is the worst outcome
//! available: the config looks right, the window opens in the wrong place, and
//! there is nothing to read that says why.
//!
//! The match half is `class:` and `title:`, both regular expressions, both
//! compiled here so a broken one is a diagnostic against the line that wrote it
//! rather than a warning in the compositor log nobody reads.
use std::fmt::Write as _;
use crate::parser::Span;
/// Where a matching window opens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceTarget {
/// 1-based, as the user counts them.
Index(u32),
/// Matched against the workspace name, which is what a `workspace` line
/// sets.
Name(String),
}
/// One `windowrule = ...` line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowRuleDecl {
/// Regular expression for the window's app id. Empty matches anything.
pub class: String,
/// Regular expression for the window's title. Empty matches anything.
pub title: String,
pub workspace: WorkspaceTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowRuleError {
pub message: String,
pub help: Option<String>,
pub span: Span,
}
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> WindowRuleError {
WindowRuleError {
message: message.into(),
help: help.map(str::to_string),
span,
}
}
/// The actions Hyprland has that this cannot do, and why saying so beats
/// guessing. `float` and `tile` are called out separately because they are the
/// next most likely thing to be reached for and COSMIC does have a mechanism --
/// just not one cosmic.conf owns.
const FLOAT_HELP: &str = "cosmic-comp decides floating from its tiling exceptions, which belong to \
cosmic-settings (com.system76.CosmicSettings.WindowRules) rather than to \
cosmic.conf. Add the application there and it will float on every \
workspace.";
const ACTION_HELP: &str = "only `workspace` is supported. Hyprland's other rules -- float, size, \
move, opacity, bordercolor and the rest -- have no COSMIC equivalent to \
project onto.";
/// Matchers Hyprland has that depend on window state at match time. Ours runs
/// once, when the window is mapped, so none of these can be answered.
const MATCHER_HELP: &str = "rules are matched once, as the window opens, so only what the window \
arrives with can be tested: class and title.";
/// Parse the body of a `windowrule` line.
///
/// `windowrule = workspace 4, class:^(vivaldi.*)$`
///
/// The first field is the action, the rest are matchers. `windowrulev2` is the
/// same thing under an older name -- Hyprland merged v2's syntax into
/// `windowrule` and kept the alias, and HyDE-era configs are full of it.
pub fn parse_window_rule(value: &str, span: Span) -> Result<WindowRuleDecl, WindowRuleError> {
let mut parts = value.split(',');
let action = parts
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
err(
span,
"a window rule needs an action",
Some("for example: windowrule = workspace 4, class:^(vivaldi)$"),
)
})?;
let workspace = parse_action(action, span)?;
let mut class = String::new();
let mut title = String::new();
let mut matched_on = false;
for raw in parts {
let param = raw.trim();
if param.is_empty() {
continue;
}
let Some((key, arg)) = param.split_once(':') else {
return Err(err(
span,
format!("expected `key:value`, found `{param}`"),
Some("known matchers: class, title"),
));
};
let arg = arg.trim();
// `initialClass`/`initialTitle` are accepted as spellings of the same
// thing rather than as approximations of it: the match happens as the
// window is mapped, so the title being tested *is* the initial one.
match key.trim().to_ascii_lowercase().as_str() {
"class" | "initialclass" => {
check_regex(arg, "class", span)?;
class = arg.to_string();
matched_on = true;
}
"title" | "initialtitle" => {
check_regex(arg, "title", span)?;
title = arg.to_string();
matched_on = true;
}
// Named rather than swept into the catch-all so the message can say
// why a matcher Hyprland does have is not accepted here.
"floating" | "fullscreen" | "pinned" | "focus" | "workspace" | "onworkspace"
| "xwayland" | "tag" | "fullscreenstate" => {
return Err(err(
span,
format!("`{}:` cannot be matched on", key.trim()),
Some(MATCHER_HELP),
));
}
other => {
return Err(err(
span,
format!("unknown matcher `{other}`"),
Some("known matchers: class, title"),
));
}
}
}
if !matched_on {
return Err(err(
span,
"a window rule needs something to match on",
Some(
"without a class or a title the rule matches every window, which \
would send the whole session to one workspace.",
),
));
}
Ok(WindowRuleDecl {
class,
title,
workspace,
})
}
/// `workspace 4`, `workspace name:web`, either with a trailing `silent`.
fn parse_action(action: &str, span: Span) -> Result<WorkspaceTarget, WindowRuleError> {
let (verb, rest) = match action.split_once(char::is_whitespace) {
Some((verb, rest)) => (verb, rest.trim()),
None => (action, ""),
};
if !verb.eq_ignore_ascii_case("workspace") {
return Err(err(
span,
format!("unsupported window rule `{verb}`"),
Some(
if verb.eq_ignore_ascii_case("float") || verb.eq_ignore_ascii_case("tile") {
FLOAT_HELP
} else {
ACTION_HELP
},
),
));
}
// Hyprland's `silent` means "put it there without switching to it". That is
// unconditionally what happens here -- a rule places its window and leaves
// the focus alone -- so the word is accepted as a description of the
// behaviour rather than ignored as a request that went unheard.
//
// Stripped from the end rather than parsed as one word among several,
// because a workspace name may contain spaces: `workspace = 2, name:web and
// mail` is a legal declaration, so `workspace name:web and mail` has to be a
// legal rule.
let target = rest
.rsplit_once(char::is_whitespace)
.filter(|(_, last)| last.eq_ignore_ascii_case("silent"))
.map_or(rest, |(head, _)| head.trim_end());
if target.is_empty() {
return Err(err(
span,
"`workspace` needs a workspace to send the window to",
Some("for example: workspace 4, or workspace name:web"),
));
}
if let Some(name) = target.strip_prefix("name:") {
if name.is_empty() {
return Err(err(span, "`name:` needs a workspace name", None));
}
return Ok(WorkspaceTarget::Name(name.to_string()));
}
// Digits checked before parsing, not after: `u32::from_str` accepts a
// leading `+`, so `workspace +1` -- Hyprland's "one to the right" -- would
// otherwise parse as the absolute workspace 1 and send the window somewhere
// the rule never asked for.
if target.bytes().all(|b| b.is_ascii_digit()) {
match target.parse::<u32>() {
Ok(0) => return Err(err(span, "workspaces are numbered from 1", None)),
Ok(index) => return Ok(WorkspaceTarget::Index(index)),
// Only reachable by overflow, which the message below covers.
Err(_) => {}
}
}
// Everything else Hyprland accepts here is relative to where you are --
// `+1`, `previous`, `empty`, `special` -- and a rule fires when a window
// opens, so "the next workspace" would mean a different one every time.
Err(err(
span,
format!("cannot send a window to `{target}`"),
Some(
"a rule names one fixed workspace: a number, or `name:` and the name \
from a `workspace` line, optionally followed by `silent`. Relative \
and special workspaces have no COSMIC equivalent.",
),
))
}
fn check_regex(pattern: &str, field: &str, span: Span) -> Result<(), WindowRuleError> {
if pattern.is_empty() {
return Err(err(
span,
format!("`{field}:` needs a value"),
Some("an empty expression matches every window; leave the matcher out instead."),
));
}
regex::Regex::new(pattern).map_err(|e| {
// The crate's own message is multi-line and already points at the
// offending character, which is more useful than anything paraphrased.
err(
span,
format!("`{field}:` is not a valid expression"),
Some(&e.to_string().replace('\n', " ")),
)
})?;
Ok(())
}
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
}
/// Render the declarations as the RON `Vec<WindowRule>` cosmic-comp stores in
/// `window_rules`.
///
/// Order is preserved because it is meaningful: the compositor takes the first
/// rule that matches, and a file reads top to bottom, so the earlier line is
/// the one a person expects to win.
pub fn render(decls: &[WindowRuleDecl]) -> String {
let mut out = String::from("[\n");
for decl in decls {
let workspace = match &decl.workspace {
WorkspaceTarget::Index(n) => format!("Index({n})"),
WorkspaceTarget::Name(name) => format!("Name({})", ron_string(name)),
};
let _ = writeln!(
out,
" (app_id: {}, title: {}, workspace: {workspace}),",
ron_string(&decl.class),
ron_string(&decl.title),
);
}
out.push_str("]\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn span() -> Span {
Span {
line: 1,
col: 1,
len: 1,
}
}
fn ok(s: &str) -> WindowRuleDecl {
parse_window_rule(s, span()).expect(s)
}
fn fail(s: &str) -> WindowRuleError {
parse_window_rule(s, span()).expect_err(s)
}
#[test]
fn the_hyprland_form_parses() {
let r = ok("workspace 4, class:^(vivaldi.*)$");
assert_eq!(r.workspace, WorkspaceTarget::Index(4));
assert_eq!(r.class, "^(vivaldi.*)$");
assert_eq!(r.title, "");
}
#[test]
fn a_named_workspace_is_carried_through() {
assert_eq!(
ok("workspace name:web, class:vivaldi").workspace,
WorkspaceTarget::Name("web".into())
);
}
#[test]
fn class_and_title_can_both_be_given() {
let r = ok("workspace 2, class:^(firefox)$, title:.*Mail.*");
assert_eq!(r.class, "^(firefox)$");
assert_eq!(r.title, ".*Mail.*");
}
#[test]
fn a_title_alone_is_enough_to_match_on() {
let r = ok("workspace 2, title:.*Mail.*");
assert_eq!(r.class, "", "an empty class matches every app id");
assert_eq!(r.title, ".*Mail.*");
}
#[test]
fn initial_spellings_are_the_same_matchers() {
let r = ok("workspace 1, initialClass:foo, initialTitle:bar");
assert_eq!(r.class, "foo");
assert_eq!(r.title, "bar");
}
#[test]
fn silent_is_accepted_because_it_describes_what_happens() {
assert_eq!(
ok("workspace 3 silent, class:foo").workspace,
WorkspaceTarget::Index(3)
);
}
#[test]
fn case_does_not_matter_for_keywords() {
assert_eq!(
ok("Workspace 3 SILENT, CLASS:foo").workspace,
WorkspaceTarget::Index(3)
);
}
#[test]
fn whitespace_around_everything_is_tolerated() {
let r = ok(" workspace 4 , class : ^foo$ ");
assert_eq!(r.workspace, WorkspaceTarget::Index(4));
assert_eq!(r.class, "^foo$");
}
#[test]
fn a_rule_with_nothing_to_match_on_is_refused() {
let e = fail("workspace 4");
assert!(e.message.contains("something to match on"), "{e:?}");
}
#[test]
fn an_unsupported_action_says_which_one_is_supported() {
let e = fail("size 100 100, class:foo");
assert!(e.message.contains("unsupported window rule"), "{e:?}");
assert!(e.help.unwrap().contains("only `workspace`"));
}
#[test]
fn float_points_at_the_tiling_exceptions_instead() {
let e = fail("float, class:foo");
assert!(
e.help.as_deref().unwrap_or_default().contains("cosmic-settings"),
"{e:?}"
);
}
#[test]
fn a_state_matcher_explains_that_matching_happens_once() {
let e = fail("workspace 4, class:foo, floating:1");
assert!(e.message.contains("cannot be matched on"), "{e:?}");
assert!(e.help.unwrap().contains("as the window opens"));
}
#[test]
fn an_unknown_matcher_lists_the_known_ones() {
let e = fail("workspace 4, klass:foo");
assert!(e.message.contains("unknown matcher"), "{e:?}");
}
/// `u32::from_str` accepts a leading sign, so `+1` would silently become
/// the absolute workspace 1 if the digits were not checked first.
#[test]
fn a_relative_workspace_is_refused_with_a_reason() {
for target in ["+1", "-1", "previous", "empty", "e+1"] {
let e = fail(&format!("workspace {target}, class:foo"));
assert!(
e.message.contains("cannot send a window to"),
"{target}: {}",
e.message
);
assert!(
e.help.as_deref().unwrap_or_default().contains("one fixed workspace"),
"{target}: {:?}",
e.help
);
}
}
#[test]
fn a_special_workspace_is_refused() {
assert!(fail("workspace special:magic, class:foo")
.message
.contains("cannot send a window to"));
}
#[test]
fn workspace_zero_is_refused() {
assert!(fail("workspace 0, class:foo")
.message
.contains("numbered from 1"));
}
#[test]
fn a_broken_expression_is_caught_here_not_in_the_compositor() {
let e = fail("workspace 4, class:^(unclosed");
assert!(e.message.contains("not a valid expression"), "{e:?}");
assert!(e.help.is_some(), "the regex crate's own message is passed on");
}
#[test]
fn an_empty_matcher_is_refused_rather_than_matching_everything() {
let e = fail("workspace 4, class:");
assert!(e.message.contains("needs a value"), "{e:?}");
}
/// `silent` is stripped off the end, so anything else trailing an index is
/// part of the target and fails as one rather than being quietly dropped.
#[test]
fn a_modifier_that_is_not_silent_is_refused() {
let e = fail("workspace 4 loud, class:foo");
assert!(e.message.contains("cannot send a window to `4 loud`"), "{e:?}");
}
/// A `workspace` line accepts a name with spaces in it, so a rule aiming at
/// that workspace has to as well.
#[test]
fn a_workspace_name_may_contain_spaces() {
assert_eq!(
ok("workspace name:web and mail, class:foo").workspace,
WorkspaceTarget::Name("web and mail".into())
);
assert_eq!(
ok("workspace name:web and mail silent, class:foo").workspace,
WorkspaceTarget::Name("web and mail".into())
);
}
#[test]
fn workspace_with_nothing_after_it_says_so() {
let e = fail("workspace, class:foo");
assert!(e.message.contains("needs a workspace"), "{e:?}");
}
#[test]
fn rendering_matches_the_ron_shape_cosmic_comp_reads() {
let out = render(&[
WindowRuleDecl {
class: "^(vivaldi)$".into(),
title: String::new(),
workspace: WorkspaceTarget::Name("web".into()),
},
WindowRuleDecl {
class: "^(kitty)$".into(),
title: String::new(),
workspace: WorkspaceTarget::Index(1),
},
]);
assert_eq!(
out,
concat!(
"[\n",
" (app_id: \"^(vivaldi)$\", title: \"\", workspace: Name(\"web\")),\n",
" (app_id: \"^(kitty)$\", title: \"\", workspace: Index(1)),\n",
"]\n",
)
);
}
#[test]
fn nothing_renders_as_an_empty_list() {
assert_eq!(render(&[]), "[\n]\n");
}
#[test]
fn a_quote_in_an_expression_cannot_break_out_of_the_ron() {
let out = render(&[WindowRuleDecl {
class: r#"^(say "hi")$"#.into(),
title: String::new(),
workspace: WorkspaceTarget::Index(1),
}]);
assert!(out.contains(r#"\"hi\""#), "{out}");
}
}
+482
View File
@@ -0,0 +1,482 @@
//! Hyprland `workspace` lines -> COSMIC pinned workspaces.
//!
//! COSMIC's workspaces are dynamic and unconditionally so: `ensure_last_empty`
//! keeps exactly one trailing empty workspace and garbage-collects every other
//! empty one, and there is no setting anywhere that turns that off. A Hyprland
//! user expects the opposite -- a fixed set of workspaces that exist whether or
//! not anything is on them, so that "workspace 4 is the browser" stays true
//! across a reboot.
//!
//! The primitive that bridges the two already exists in the compositor.
//! `Workspace::can_auto_remove` is `is_empty() && !has_activation_token() &&
//! !pinned`, so a pinned workspace survives being emptied, and
//! `CosmicCompConfig::pinned_workspaces` is a persisted key that
//! `Workspaces::add_output` drains into the first output that appears. Nothing
//! in cosmic-comp needs to change: declaring workspaces here is enough.
//!
//! Three consequences of that restore path shape this module:
//!
//! 1. **Restore is positional.** `PinnedWorkspace` has no index field -- the
//! order of the Vec becomes the order of the workspaces. So `workspace = 4`
//! cannot emit one entry; it has to emit four, with 1..3 unnamed, or the
//! declared workspace would land at index 1.
//! 2. **The trailing dynamic workspace is kept.** Pinned workspaces are pushed
//! into an empty `WorkspaceSet`, and `ensure_last_empty` then appends the
//! usual empty one. Declaring four leaves you on 1-4 with a fifth appearing
//! when you use it, which is Hyprland's behaviour rather than a compromise.
//! 3. **It lands at the next login, not on apply.** `Workspaces::new` reads the
//! key once when the compositor starts and there is no reload path for it,
//! while `hyprcosmic-conf watch` is started from the autostart file *after*
//! COSMIC's own components. So an edit is written immediately and takes
//! effect the next time the session starts. Every other key in cosmic.conf
//! is live, so this one is worth saying out loud.
use std::fmt::Write as _;
use crate::parser::Span;
/// One `workspace = ...` line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceDecl {
/// 1-based, as written. Workspaces below this one are materialised too.
pub index: u32,
/// Shown by anything reading ext-workspace, waybar included. `None` leaves
/// COSMIC to label it by number.
pub name: Option<String>,
/// Per-workspace tiling. `None` inherits the session default.
pub tiling: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceError {
pub message: String,
pub help: Option<String>,
pub span: Span,
}
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> WorkspaceError {
WorkspaceError {
message: message.into(),
help: help.map(str::to_string),
span,
}
}
/// Declaring index N materialises N workspaces, so a typo like `workspace = 100`
/// would silently produce a hundred of them. Nobody drives a hundred
/// workspaces; the cap turns a fat-finger into a diagnostic.
const MAX_INDEX: u32 = 32;
/// Why `monitor:` is refused rather than accepted and ignored.
///
/// A `PinnedWorkspace` names its output through `OutputMatch { name, edid }`,
/// and cosmic-comp's `output_matches` compares the EDID *first*: a match with
/// `edid: None` is rejected outright against any output that reports one, and
/// only falls through to the name when neither side has an EDID. Every real
/// panel reports one, so a name-only match would work on a VM and nowhere else.
///
/// cosmic.conf cannot supply the EDID -- it is a manufacturer triple, product
/// id, serial and manufacture date read off the wire by the DRM backend, not
/// something a user can write down. Accepting `monitor:` would give a parameter
/// that silently does nothing on the hardware people actually have.
const MONITOR_HELP: &str = "COSMIC matches a workspace to a monitor by EDID rather than by name, \
and cosmic.conf has no way to spell an EDID. Pinned workspaces are created \
on the first output the session sees; move them with the workspace \
shortcuts once they exist.";
/// Parse the body of a `workspace` line.
///
/// `workspace = 4, name:web, tiling:true`
///
/// The index comes first and is required. Hyprland also allows a leading
/// `name:` with no index, for its special workspaces; COSMIC has no equivalent
/// and the restore is positional besides, so that form is rejected with an
/// explanation rather than guessed at.
pub fn parse_workspace(value: &str, span: Span) -> Result<WorkspaceDecl, WorkspaceError> {
let mut parts = value.split(',');
let head = parts
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
err(
span,
"a workspace needs an index",
Some("for example: workspace = 4, name:web"),
)
})?;
let index = head.parse::<u32>().map_err(|_| {
if head.contains(':') {
err(
span,
format!("a workspace starts with its index, found `{head}`"),
Some(
"COSMIC restores pinned workspaces by position, so every one \
needs a number. Write `workspace = 4, name:web` rather than \
`workspace = name:web`.",
),
)
} else {
err(
span,
format!("expected a workspace index, found `{head}`"),
Some("for example: workspace = 4, name:web"),
)
}
})?;
if index == 0 || index > MAX_INDEX {
return Err(err(
span,
format!("workspace index {index} is outside 1..={MAX_INDEX}"),
Some(
"workspaces are numbered from 1, and declaring one materialises \
every workspace below it, so the highest index is capped.",
),
));
}
let mut decl = WorkspaceDecl {
index,
name: None,
tiling: None,
};
for raw in parts {
let param = raw.trim();
if param.is_empty() {
continue;
}
let Some((key, arg)) = param.split_once(':') else {
return Err(err(
span,
format!("expected `key:value`, found `{param}`"),
Some("known parameters: name, tiling"),
));
};
let arg = arg.trim();
match key.trim().to_ascii_lowercase().as_str() {
"name" => {
if arg.is_empty() {
return Err(err(span, "`name:` needs a value", None));
}
decl.name = Some(arg.to_string());
}
// Named rather than swept into the catch-all so the message can say
// why a parameter Hyprland does have is not accepted here.
"monitor" | "output" => {
return Err(err(
span,
format!("`{}:` has no COSMIC equivalent", key.trim()),
Some(MONITOR_HELP),
));
}
"tiling" => {
decl.tiling = Some(match arg.to_ascii_lowercase().as_str() {
"true" | "yes" | "on" | "1" => true,
"false" | "no" | "off" | "0" => false,
other => {
return Err(err(
span,
format!("`tiling:` expects a boolean, found `{other}`"),
None,
))
}
});
}
other => {
return Err(err(
span,
format!("unknown workspace parameter `{other}`"),
Some("known parameters: name, tiling"),
));
}
}
}
Ok(decl)
}
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
}
/// FNV-1a, written out rather than reached for.
///
/// `DefaultHasher` is explicitly documented as not stable across Rust releases,
/// which would make workspace ids change under the user when the toolchain
/// moves -- and the id is what ties a window's saved workspace to the workspace
/// it reappears on. Twelve lines of FNV is cheaper than that class of bug.
fn fnv1a(s: &str) -> u32 {
let mut h: u32 = 0x811c9dc5;
for b in s.as_bytes() {
h ^= *b as u32;
h = h.wrapping_mul(0x01000193);
}
h
}
/// A stable id for the workspace at `index`.
///
/// cosmic-comp's own `random_workspace_id` is `format!("{:x}", rand(0..2<<24))`,
/// which is at most seven hex digits. Forcing the high nibble on here makes ours
/// exactly eight, so a generated id and a compositor-generated one cannot
/// collide by construction rather than by being unlikely to.
fn workspace_id(index: u32) -> String {
let h = fnv1a(&format!("hyprcosmic:workspace:{index}"));
format!("{:08x}", 0x1000_0000 | (h & 0x0fff_ffff))
}
/// Render the declarations as the RON `Vec<PinnedWorkspace>` cosmic-comp stores
/// in `pinned_workspaces`.
///
/// `default_tiling` is the session-wide `general:autotile`, used for any
/// workspace that did not say. Without it, declaring workspaces would silently
/// turn tiling off on all of them for a user who had asked for it globally.
pub fn render(decls: &[WorkspaceDecl], default_tiling: bool) -> String {
let highest = decls.iter().map(|d| d.index).max().unwrap_or(0);
let mut out = String::from("[\n");
for index in 1..=highest {
// Gaps are filled rather than skipped: the restore is positional, so an
// absent workspace 3 would put the declared workspace 4 at position 3.
let decl = decls.iter().find(|d| d.index == index);
let tiling = decl.and_then(|d| d.tiling).unwrap_or(default_tiling);
let name = match decl.and_then(|d| d.name.as_deref()) {
Some(n) => format!("Some({})", ron_string(n)),
None => "None".to_string(),
};
// An empty `OutputMatch` is "no preference", not a guess. It is the
// front of the workspace's `output_stack`, which `prefers_output`
// consults when an output appears; a name nothing can match leaves the
// workspace where `add_output` created it. See MONITOR_HELP.
let _ = writeln!(
out,
r#" (output: (name: "", edid: None), tiling_enabled: {tiling}, id: Some({}), name: {name}),"#,
ron_string(&workspace_id(index)),
);
}
out.push_str("]\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
const S: Span = Span {
line: 1,
col: 1,
len: 1,
};
fn ws(v: &str) -> WorkspaceDecl {
parse_workspace(v, S).expect("should parse")
}
fn bad(v: &str) -> WorkspaceError {
parse_workspace(v, S).expect_err("should not parse")
}
#[test]
fn the_shortest_useful_declaration_is_a_bare_index() {
assert_eq!(
ws("4"),
WorkspaceDecl {
index: 4,
name: None,
tiling: None,
}
);
}
#[test]
fn every_parameter_together() {
assert_eq!(
ws("4, name:web, tiling:true"),
WorkspaceDecl {
index: 4,
name: Some("web".into()),
tiling: Some(true),
}
);
}
#[test]
fn parameters_are_order_independent_and_tolerate_spacing() {
assert_eq!(ws("2,tiling:off,name:mail"), ws("2, name:mail, tiling:off"));
}
/// Hyprland's `monitor:` is the parameter a user is most likely to reach
/// for, and the one COSMIC cannot honour. Accepting and ignoring it would
/// be the worst of the three options, so it must fail and say why.
#[test]
fn monitor_is_refused_with_the_reason_rather_than_silently_ignored() {
for spelling in ["monitor", "output"] {
let e = bad(&format!("1, {spelling}:eDP-1"));
assert!(
e.message.contains("no COSMIC equivalent"),
"{spelling}: {}",
e.message
);
assert!(
e.help.as_deref().unwrap_or_default().contains("EDID"),
"{spelling}: {:?}",
e.help
);
}
}
#[test]
fn a_name_may_contain_spaces() {
assert_eq!(ws("1, name:web and mail").name.as_deref(), Some("web and mail"));
}
/// Hyprland's named-workspace form has no COSMIC equivalent, and the failure
/// if it were guessed at would be workspaces in the wrong order.
#[test]
fn the_hyprland_name_first_form_is_refused_with_the_reason() {
let e = bad("name:web, tiling:true");
assert!(e.message.contains("starts with its index"), "{}", e.message);
assert!(
e.help.as_deref().unwrap_or_default().contains("by position"),
"{:?}",
e.help
);
}
#[test]
fn workspace_zero_does_not_exist() {
let e = bad("0");
assert!(e.message.contains("outside 1..="), "{}", e.message);
}
/// The cap is the whole reason this check exists: without it the typo below
/// creates a thousand workspaces rather than reporting anything.
#[test]
fn an_absurd_index_is_a_diagnostic_not_a_thousand_workspaces() {
let e = bad("1000");
assert!(e.message.contains("outside 1..=32"), "{}", e.message);
}
#[test]
fn an_unknown_parameter_lists_the_known_ones() {
let e = bad("1, gapsin:0");
assert!(e.message.contains("gapsin"), "{}", e.message);
assert!(
e.help.as_deref().unwrap_or_default().contains("name, tiling"),
"{:?}",
e.help
);
}
#[test]
fn a_parameter_without_a_colon_is_rejected() {
let e = bad("1, web");
assert!(e.message.contains("key:value"), "{}", e.message);
}
#[test]
fn tiling_takes_the_same_boolean_spellings_as_the_rest_of_the_file() {
for v in ["true", "yes", "on", "1"] {
assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(true), "{v}");
}
for v in ["false", "no", "off", "0"] {
assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(false), "{v}");
}
assert!(bad("1, tiling:sometimes").message.contains("boolean"));
}
/// The property the positional restore turns on: declaring only 4 must still
/// emit 1, 2 and 3, or the browser workspace comes back as workspace 1.
#[test]
fn declaring_one_high_index_materialises_every_workspace_below_it() {
let ron = render(&[ws("4, name:web")], false);
assert_eq!(ron.lines().filter(|l| l.contains("output:")).count(), 4, "{ron}");
let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect();
assert!(lines[0].contains("name: None"), "{}", lines[0]);
assert!(lines[1].contains("name: None"), "{}", lines[1]);
assert!(lines[2].contains("name: None"), "{}", lines[2]);
assert!(lines[3].contains(r#"name: Some("web")"#), "{}", lines[3]);
}
#[test]
fn gaps_between_declarations_are_filled_in_order() {
let ron = render(&[ws("3, name:code"), ws("1, name:term")], false);
let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains(r#"name: Some("term")"#), "{}", lines[0]);
assert!(lines[1].contains("name: None"), "{}", lines[1]);
assert!(lines[2].contains(r#"name: Some("code")"#), "{}", lines[2]);
}
#[test]
fn no_declarations_render_as_an_empty_list() {
assert_eq!(render(&[], false), "[\n]\n");
}
/// A workspace that did not mention tiling must not quietly contradict
/// `general:autotile`.
#[test]
fn unset_tiling_follows_the_session_default_both_ways() {
assert!(render(&[ws("1")], true).contains("tiling_enabled: true"));
assert!(render(&[ws("1")], false).contains("tiling_enabled: false"));
// ...and an explicit value still wins over it.
assert!(render(&[ws("1, tiling:false")], true).contains("tiling_enabled: false"));
}
/// Not a placeholder: an unmatchable `OutputMatch` is how a workspace says
/// it has no output preference, which is the only thing cosmic.conf can
/// truthfully express. See MONITOR_HELP.
#[test]
fn the_output_match_is_always_empty() {
assert!(render(&[ws("1")], false).contains(r#"output: (name: "", edid: None)"#));
}
/// Ids have to survive a re-apply, or every `hyprcosmic-conf apply` would hand
/// the same workspaces new identities.
#[test]
fn ids_are_stable_across_runs_and_unique_per_index() {
assert_eq!(workspace_id(4), workspace_id(4));
let ids: BTreeSet<String> = (1..=MAX_INDEX).map(workspace_id).collect();
assert_eq!(ids.len(), MAX_INDEX as usize, "an index collided");
}
/// cosmic-comp's `random_workspace_id` is `{:x}` of a number below 2<<24,
/// so it is never more than seven hex digits. Ours are always eight, which
/// is what makes a collision impossible rather than unlikely.
#[test]
fn ids_cannot_collide_with_a_compositor_generated_one() {
for i in 1..=MAX_INDEX {
let id = workspace_id(i);
assert_eq!(id.len(), 8, "{id}");
assert!(
u32::from_str_radix(&id, 16).unwrap() >= 0x1000_0000,
"{id} is inside the compositor's own range"
);
}
}
#[test]
fn a_quote_in_a_name_cannot_break_out_of_the_ron() {
let ron = render(&[ws(r#"1, name:say "hi""#)], false);
assert!(ron.contains(r#"name: Some("say \"hi\"")"#), "{ron}");
}
}
+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"
);
}
@@ -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.
+31 -2
View File
@@ -8,6 +8,10 @@ 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
@@ -29,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
@@ -58,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
@@ -80,6 +108,7 @@ 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
+67
View File
@@ -0,0 +1,67 @@
# 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')
# cosmic-session is COSMIC itself, which this runs on rather than replaces: one
# entry pulls the whole desktop, and cosmic-settings, cosmic-osd, the portal and
# the rest are taken from the repositories at the version they were tested at.
#
# The rest is the HyDE shell. Without those the session starts to a blank
# screen: no bar, no launcher, no wallpaper.
depends=('cosmic-session' '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')
# No conflicts and no provides, deliberately. This installs beside COSMIC: its
# binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
# writes no path that cosmic-comp or cosmic-session owns. An earlier revision
# took the cosmic-* names and could not be installed over a COSMIC system
# without erasing the desktop it forked.
options=('!strip' '!debug')
package() {
# Points at a tree `just install` has already staged. Failing loudly here
# beats producing an empty package, which is what a bare `cp -a "$unset/."`
# would do. The workflow sets it; by hand it is
# just install "$PWD/stage" /usr
# HYPRCOSMIC_STAGEDIR="$PWD/stage" makepkg --nodeps
if [ -z "$HYPRCOSMIC_STAGEDIR" ] || [ ! -d "$HYPRCOSMIC_STAGEDIR/usr" ]; then
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see .github/workflows/packages.yml" >&2
return 1
fi
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
# No desktop-file-validate on the session entry. It rejects DesktopNames,
# the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
# Desktop Entry Specification registers keys for application launchers and
# this is a session file. The copy Fedora ships as cosmic-session-1.5.0-1.fc44
# fails the identical check -- so this is the validator's gap, not something
# the fork introduced. The workflow checks what actually matters instead --
# that Exec names a file this package installs -- against the staged tree,
# before makepkg sees it.
}
+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
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
hyprcosmic-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 carries the whole desktop, which on Debian it has to: COSMIC is
not packaged there, in any suite, so there is nothing to depend on and nothing
to install beside. The Fedora and Arch packages ship only this fork's three
binaries and take the rest from the distribution. Here the forked binaries are
hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, the components they
launch are included, and the session entry is the HyprCosmic one alone.
+167
View File
@@ -0,0 +1,167 @@
# 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 WHY IT IS A SMALL ONE
# ------------------------------------------------
# Fedora splits COSMIC into a package per component, which is right for a
# distribution tracking upstream. This fork changes three of them -- the
# compositor, the session and the config compiler it adds -- and they are
# versioned and tested together, so one package is an accurate description of
# what is actually supported.
#
# It is not a package per component and it is not the whole desktop either. The
# build tree produces all of COSMIC, because it is COSMIC's tree, but shipping
# all of it would mean owning files that 25 distribution packages already own.
# The workflow reduces the staged tree to what this fork actually produces
# before any of the three packaging recipes see it.
%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
# COSMIC itself, which this runs on rather than replaces.
#
# One line pulls the whole desktop, because cosmic-session requires every
# component. That is exactly what is wanted: HyprCosmic forks the compositor,
# the session and adds the config compiler, and takes cosmic-settings,
# cosmic-osd, the portal and the rest from the distribution at the version the
# distribution tested them at.
#
# There is deliberately no Conflicts and no Provides here. An earlier revision
# had both, on the reading that a fork of the desktop replaces the desktop, and
# it could not be installed: this package's files collided with 25 others in
# rpm's transaction check, and satisfying that by claiming all 25 with Conflicts
# would have erased cosmic-greeter, which on a stock Fedora COSMIC is the
# display manager. Installing beside COSMIC costs three renamed binaries and
# leaves the stock session on the greeter's menu to fall back to.
Requires: cosmic-session >= 1.5.0
# 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
# Something for the wallpaper daemon to draw before a theme has been imported.
# hyprcosmic-wallpaper looks here first when ~/.local/share/wallpapers has no
# `current` link yet, which on a machine that has just installed this package is
# always. Nothing in the dependency chain pulls it in otherwise -- neither
# cosmic-session nor cosmic-comp requires it -- and without it a first login is
# a bar on a black screen.
Recommends: cosmic-wallpapers
# Nerd Font glyphs are most of what the bar and the launcher draw, and Fedora
# has nothing that provides them. This line used to say `nerd-fonts`, which is
# not a package in any Fedora repository -- so it could never be satisfied, and
# the fonts never arrived, silently, because that is what Recommends does when
# it cannot resolve. The two patched fonts Fedora does ship are no help either:
# texlive-inconsolata-nerd-font lands under texmf-dist and kitty's
# SymbolsNerdFont under /usr/lib64/kitty, and fontconfig scans neither path.
#
# So recommend the fallback the configuration actually names. rules.rasi asks
# for "JetBrainsMono Nerd Font, Noto Sans Mono": this is the second of the two.
# It cannot draw a single glyph, but it decides what the text is set in instead
# of leaving that to whatever fontconfig happens to pick. A virtual provide
# rather than a name, because two packages carry it and either will do.
#
# The glyphs themselves have to be installed by hand. See README.md.
Recommends: font(notosansmono)
%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
hyprcosmic-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 installs beside the distribution's COSMIC rather than over it. Its
binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
adds one session entry; the stock COSMIC entry stays on the greeter's menu,
served by the distribution's own binaries, so a session that will not start is
one logout away from a desktop that will.
%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 session entry is checked by the workflow against the staged tree, not
# with desktop-file-validate here. desktop-file-validate rejects DesktopNames,
# the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
# Desktop Entry Specification registers keys for application launchers and this
# is a session file. The copy Fedora already ships as cosmic-session-1.5.0-1.fc44
# fails the identical check -- so this is the validator's gap, not something the
# fork introduced. Dropping the key would satisfy the validator and break the
# session.
# See "Check the session entries" in .github/workflows/packages.yml, which
# tests what actually matters: that Exec names a file this package installs.
# 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
* Wed Aug 12 2026 dingo <[email protected]> - 0.1.1-1
- Seed ~/.config from /usr/share/hyprcosmic/skel at every session start, so a
machine that has never seen HyprCosmic logs into a configured desktop rather
than a bare compositor. Existing files are never overwritten.
- Recommend font(notosansmono) in place of nerd-fonts, which is not a package
in any Fedora repository and so could never have been installed.
- Recommend cosmic-wallpapers, and add hyprcosmic-wallpaper, which falls back
to it when no theme has been imported yet.
* Mon Aug 10 2026 dingo <[email protected]> - 0.1.0-1
- First package of the fork: hyprcosmic-comp, hyprcosmic-session and
hyprcosmic-conf installed beside the distribution's COSMIC, with a HyDE shell.
+1 -1
View File
@@ -3,7 +3,7 @@
set -e
# This should be the _next_ epoch version
version=1.4.0
version=1.5.0
subject="Epoch ${version} version update"
description="Generated by cosmic-epoch scripts/version-update.sh"
+258
View File
@@ -0,0 +1,258 @@
#!/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"
"config/bin/hyprcosmic-fan:bin/hyprcosmic-fan:755"
"config/bin/hyprcosmic-keybinds:bin/hyprcosmic-keybinds:755"
"config/bin/hyprcosmic-wallpaper:bin/hyprcosmic-wallpaper: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
)
# Per-user files. These cannot live in share/hyprcosmic with the rest: style.css
# and config.rasi reach their theme through relative @imports, which resolve
# against the importing file, so the importer has to sit in the same per-user
# directory as the theme it picks up.
#
# They are still installed, as a skeleton. start-hyprcosmic copies anything
# missing out of share/hyprcosmic/skel into $XDG_CONFIG_HOME when a session
# starts, and never overwrites. Destinations below therefore mirror the layout
# under ~/.config exactly -- skel/hyprcosmic/autostart becomes
# ~/.config/hyprcosmic/autostart -- because the seeding is a plain copy that
# reads the layout off this tree rather than a list it keeps in step by hand.
#
# Before this existed the answer was "by hand", which meant a machine that had
# never seen HyprCosmic logged into a bare compositor: no autostart, so no
# waybar and no wallpaper, and no keybindings, on a screen with nothing drawn on
# it. Nothing reported an error, because from the session's point of view
# nothing had gone wrong.
SKEL=(
"config/autostart:share/hyprcosmic/skel/hyprcosmic/autostart:644"
"config/cosmic.conf:share/hyprcosmic/skel/hyprcosmic/cosmic.conf:644"
"config/waybar/style.css:share/hyprcosmic/skel/hyprcosmic/waybar/style.css:644"
"config/waybar/theme.css:share/hyprcosmic/skel/hyprcosmic/waybar/theme.css:644"
"config/rofi/config.rasi:share/hyprcosmic/skel/rofi/config.rasi:644"
"config/rofi/theme.rasi:share/hyprcosmic/skel/rofi/theme.rasi:644"
"config/rofi/local.rasi:share/hyprcosmic/skel/rofi/local.rasi:644"
)
# Refuses to run unless every file under config/ appears in exactly one of
# SHARED, SKEL or SOURCES. This is not decoration: adding a file forces a
# decision about where it belongs instead of letting it be quietly left out of
# all three and never installed.
audit_config_tree() {
local f rel known=" ${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
for f in "${SKEL[@]}"; do known+="${f%%:*} "; done
while IFS= read -r -d '' f; do
rel="${f#"$REPO"/}"
[[ "$known" == *" $rel "* ]] || unclassified+=("$rel")
# Dot-directories are pruned. Nothing shipped lives in one, and tooling
# drops state inside the tree without asking -- .omc/ appeared under
# config/waybar/ and failed this audit with six files that are gitignored
# and are not assets. Failing on those trains you to ignore the one message
# that catches a genuinely unclassified file. Dot *files* are still walked;
# only directories are pruned.
done < <(find "$REPO/config" -name '.?*' -type d -prune -o -type f -print0 | sort -z)
((${#unclassified[@]} == 0)) || die "not listed as shared, skeleton or generator input: ${unclassified[*]}
Add each to SHARED, SKEL or SOURCES 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/ cosmic-session/data/ 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"
warn "start-hyprcosmic is the exception: set HYPRCOSMIC_SKEL=$PREFIX/share/hyprcosmic/skel to point the seeding at this prefix"
}
# 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[@]}" "${SKEL[@]}")
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())