Compare commits

...
11 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
30 changed files with 2654 additions and 166 deletions
+11 -2
View File
@@ -171,13 +171,19 @@ jobs:
# A login-time script with a syntax error is a black screen with nowhere to # A login-time script with a syntax error is a black screen with nowhere to
# print the reason. # 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 - name: Syntax-check the shell scripts
run: | run: |
set -eux set -eux
sh -n config/bin/hyprcosmic-fan
bash -n config/bin/hyprcosmic-keybinds
bash -n config/bin/hyprcosmic-powermenu bash -n config/bin/hyprcosmic-powermenu
bash -n tools/install-assets.sh bash -n tools/install-assets.sh
# Both scripts are shellcheck-clean today, so this starts as a ratchet # Every script is shellcheck-clean today, so this starts as a ratchet
# rather than a backlog. The runner image ships shellcheck; the install is # 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. # there so that stopping to be true is a slow step and not a broken job.
- name: Shellcheck - name: Shellcheck
@@ -187,7 +193,10 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck sudo apt-get install -y --no-install-recommends shellcheck
} }
shellcheck config/bin/hyprcosmic-powermenu tools/install-assets.sh 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 # 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 # confirm it exercises both halves of the script against each other, and
+173 -21
View File
@@ -229,28 +229,169 @@ jobs:
- name: Stage the install - name: Stage the install
run: just install "$PWD/stage" /usr run: just install "$PWD/stage" /usr
# The staged tree is what all three packages wrap, so it is worth failing # The three binaries this fork actually changes, under their own names.
# here rather than shipping a package that is missing the compositor. The #
# negative assertion is the one that would rot quietly: nothing may return # Done on every distribution, because start-hyprcosmic and the session
# to the private libexec layout this fork used to install into, because a # entry name these paths and there is no reason for those to differ per
# copy there is a second compositor that nothing runs and no uninstall # distribution. cosmic-session takes the compositor as argv[1], so the
# removes. # pair is arranged in start-hyprcosmic and needs no source change.
- name: Assert the staged tree is a complete desktop - name: Give this fork's binaries their own names
run: | run: |
set -eux set -eux
test -x stage/usr/bin/cosmic-comp mv stage/usr/bin/cosmic-comp stage/usr/bin/hyprcosmic-comp
test -x stage/usr/bin/cosmic-session mv stage/usr/bin/cosmic-session stage/usr/bin/hyprcosmic-session
test -x stage/usr/bin/cosmic-conf 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 -x stage/usr/bin/start-hyprcosmic
test -x stage/usr/bin/start-cosmic
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
test -f stage/usr/share/wayland-sessions/cosmic.desktop
test -f stage/usr/lib/systemd/user/cosmic-session.target
test -f stage/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1/defaults
test -d stage/usr/share/hyprcosmic test -d stage/usr/share/hyprcosmic
test ! -e stage/usr/libexec/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)" 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 ------------------------------------------------------- # ---- Fedora -------------------------------------------------------
# #
# The file list is generated rather than written into the spec. Across 27 # The file list is generated rather than written into the spec. Across 27
@@ -325,7 +466,11 @@ jobs:
# dependencies without touching the filesystem, and this image is not # dependencies without touching the filesystem, and this image is not
# a desktop, so a real install would fail on runtime libraries that # a desktop, so a real install would fail on runtime libraries that
# say nothing about whether the package is well formed. # say nothing about whether the package is well formed.
pacman -Qlp dist/*.pkg.tar.zst | head -20 # 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 ------------------------------------------------------- # ---- Debian -------------------------------------------------------
# #
@@ -364,7 +509,13 @@ jobs:
run: | run: |
set -eux set -eux
dpkg-deb --info dist/*.deb dpkg-deb --info dist/*.deb
dpkg-deb --contents dist/*.deb | head -20 # 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 # lintian is not installed and would fail this package on a dozen
# policy points that do not apply to a desktop fork shipped outside # policy points that do not apply to a desktop fork shipped outside
# the archive. What matters here is that dpkg can read it back. # the archive. What matters here is that dpkg can read it back.
@@ -395,10 +546,11 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: dist/* files: dist/*
# Draft, deliberately. These packages conflict with the # Draft, deliberately. These install beside the distribution's COSMIC
# distribution's cosmic-comp and cosmic-session, so installing one # rather than over it, so a bad one costs a logout rather than a
# replaces the machine's desktop. That is worth a human reading the # desktop -- but a tag push is still not a decision to publish. The
# notes before it is published rather than a tag push making it # generated notes are written by a machine reading commit subjects,
# available. # and the Debian package differs from the other two in what it
# carries; both are worth a human reading before anyone downloads.
draft: true draft: true
generate_release_notes: true generate_release_notes: true
+111 -33
View File
@@ -18,12 +18,13 @@ Three things distinguish a HyprCosmic session from a COSMIC one:
- **HyDE's shell.** waybar instead of cosmic-panel, rofi instead of - **HyDE's shell.** waybar instead of cosmic-panel, rofi instead of
cosmic-launcher, `awww` instead of cosmic-bg. HyDE themes are imported cosmic-launcher, `awww` instead of cosmic-bg. HyDE themes are imported
directly, palette and wallpapers and all. directly, palette and wallpapers and all.
- **It replaces COSMIC rather than sitting next to it.** The binaries install as - **It installs next to COSMIC rather than over it.** The binaries are
`/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, the paths a cosmic-comp `/usr/bin/hyprcosmic-comp`, `/usr/bin/hyprcosmic-session` and
and a cosmic-session go to, and the packages conflict with the distribution's `/usr/bin/hyprcosmic-conf`, and nothing here writes a path the distribution
accordingly. Both session entries are installed, so the greeter still offers a owns. The stock COSMIC entry stays on the greeter's menu, served by the
stock COSMIC shell for the day the HyDE one does not start — now served by distribution's own binaries, so the day the HyDE session does not start is one
these binaries rather than by a second copy on disk. logout away from a desktop that does. (On Debian, where COSMIC is not
packaged, the `.deb` carries the desktop itself — see [Installing](#installing).)
## Repository layout ## Repository layout
@@ -54,8 +55,9 @@ current.
deliberately small: `dispatch exec` and `dispatch killactive` are rejected, deliberately small: `dispatch exec` and `dispatch killactive` are rejected,
because this is the surface any process that can open the socket gets. because this is the surface any process that can open the socket gets.
- New windows open *beside* the focused window rather than inside it. - New windows open *beside* the focused window rather than inside it.
- The install goes to `/usr/bin/cosmic-comp`, at upstream's paths and alongside - The install goes to `/usr/bin/hyprcosmic-comp`, alongside upstream's two
upstream's two `.ron` defaults files, which are carried unmodified. `.ron` defaults files, which are carried unmodified. The distribution's
`cosmic-comp` is left where it is, for the stock session to keep using.
**cosmic-session** — profiles. `HYPRCOSMIC_PROFILE=hyprcosmic` (set by **cosmic-session** — profiles. `HYPRCOSMIC_PROFILE=hyprcosmic` (set by
`hyprcosmic.desktop`) skips cosmic-panel, cosmic-launcher, cosmic-app-library, `hyprcosmic.desktop`) skips cosmic-panel, cosmic-launcher, cosmic-app-library,
@@ -93,14 +95,24 @@ sudo pacman -U ./hyprcosmic-*.pkg.tar.zst # Arch
sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian
``` ```
Expect this to fail the first time, and read what it says when it does. These Nothing is removed and nothing conflicts. COSMIC is a dependency rather than a
packages provide `/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, so they casualty: the package installs `hyprcosmic-comp`, `hyprcosmic-session` and
**conflict with the distribution's `cosmic-comp` and `cosmic-session`** and your `hyprcosmic-conf` beside the distribution's, and takes cosmic-settings, the
package manager will refuse until those are removed. That refusal is the design: portal, the OSD and the rest from the distribution at the version it tested
installing HyprCosmic replaces the machine's desktop, and it should take a them at. Log out and pick **HyprCosmic** from the greeter; pick **COSMIC** to go
deliberate `dnf remove cosmic-comp cosmic-session` to say so rather than a back.
resolver deciding on your behalf. Both session entries survive the swap, so the
greeter still offers a stock COSMIC shell afterwards. 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.
**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.
Building it yourself instead: Building it yourself instead:
@@ -125,6 +137,13 @@ 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 `cosmic-conf` at `$prefix/bin/cosmic-conf`, the shared waybar and rofi assets
under `$prefix/share/hyprcosmic/`, and `hyprcosmic-powermenu`. under `$prefix/share/hyprcosmic/`, and `hyprcosmic-powermenu`.
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.
`install` depends on `build`, which is upstream's arrangement and means `sudo `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 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. rather not, build into a staging root as your own user and copy it into place.
@@ -134,26 +153,85 @@ Then log out. `HyprCosmic` appears on the greeter's session menu next to
### Per-user setup ### Per-user setup
`just install` places nothing in a home directory — under `sudo` the only home Nothing to do. The session seeds `~/.config` from `/usr/share/hyprcosmic/skel`
directory it could see is root's. Four files are yours to place: at login, copying only what is missing:
```shell ```
mkdir -p ~/.config/hyprcosmic/waybar ~/.config/hyprcosmic/autostart
cp config/cosmic.conf config/autostart ~/.config/hyprcosmic/ ~/.config/hyprcosmic/cosmic.conf
cp config/waybar/style.css ~/.config/hyprcosmic/waybar/ ~/.config/hyprcosmic/waybar/style.css
~/.config/hyprcosmic/waybar/theme.css
~/.config/rofi/config.rasi
~/.config/rofi/theme.rasi
~/.config/rofi/local.rasi
``` ```
`style.css` is per-user rather than shared for one reason: it `@import`s a An existing file is never touched, not even when the skeleton is newer — the
sibling `theme.css` holding the installed HyDE theme's palette, and a relative file wins, one way, and a login is not an invitation to edit your config. Two
`@import` resolves against the importing file. That sibling is written by things follow: your edits survive every login and upgrade, and deleting a file
`import-theme --assets`, so the bar is unstyled until you have imported a theme. is how you ask for the default back. `~/.cache/hyprcosmic/session.log` records
what was seeded.
The fourth file, `~/.config/rofi/config.rasi`, is written by `import-theme The cost of that is real and worth stating: an upgrade that improves a shipped
--assets` too, because it names per-machine paths. 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 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 here: `waybar`, `rofi` (wayland build), `awww` (formerly `swww`), and a Nerd
Font for the bar's glyphs. 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 ## Configuration
@@ -161,9 +239,9 @@ Font for the bar's glyphs.
`cosmic-config` by: `cosmic-config` by:
```shell ```shell
cosmic-conf apply # once hyprcosmic-conf apply # once
cosmic-conf apply --diff # show what would change, write nothing hyprcosmic-conf apply --diff # show what would change, write nothing
cosmic-conf watch # recompile on every edit, for the whole session 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 `watch` is the first line of the shipped `autostart`, which is what makes "the
@@ -208,7 +286,7 @@ the reference for what is supported.
## Theming ## Theming
```shell ```shell
cosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \ hyprcosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \
--out ~/.config/hyprcosmic/theme.conf --report --assets --out ~/.config/hyprcosmic/theme.conf --report --assets
``` ```
+15 -17
View File
@@ -23,7 +23,7 @@
# A malformed edit is not fatal. It is reported to the session log and the last # 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 # good configuration stays in place, so a typo cannot leave you at a broken
# desktop -- fix the file and the next save applies. # desktop -- fix the file and the next save applies.
cosmic-conf watch hyprcosmic-conf watch
# The bar. The layout is shared and lives under /usr/share, but the stylesheet # 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 # has to be per-user: it imports a sibling theme.css holding the installed HyDE
@@ -60,28 +60,26 @@ awww-daemon
# get a blank screen below the bar with no error anywhere: the failure is that # 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. # nobody asked for a wallpaper, so nothing reports one missing.
# #
# `sh -c` rather than a bare `awww img`, for one reason: awww-daemon above has # It sets ~/.local/share/wallpapers/hyprcosmic/current, a symlink
# only just been forked and is not listening yet, so an immediate `awww img` # `hyprcosmic-conf import-theme --assets` maintains beside the wallpapers it
# loses a race and fails silently. The loop waits for the daemon to answer # copies. The link is used rather than a real path so that this and rofi's
# before setting the image. # 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.
# #
# This does not weaken the no-shell rule in the header. That rule exists so a # Until a theme has been imported there is no such link, so on a machine that
# file naming programs cannot be escalated into arbitrary execution; naming # has just installed the package it falls back to whatever the distribution
# `sh` explicitly is just naming a program, and anyone able to write this file # ships, /usr/share/backgrounds/cosmic first. A first login gets a desktop.
# could already name any binary on the system.
#
# `current` is a symlink `cosmic-conf import-theme --assets` maintains beside
# the wallpapers it copies, pointing at one of them. It is named here rather
# than a real file so that this line and rofi's local.rasi -- which shows the
# same image in the launcher's sidebar -- cannot drift apart, and so that
# importing a different theme does not leave this pointing at a path that no
# longer exists.
# #
# Change the wallpaper by repointing the link, not by editing this file: # Change the wallpaper by repointing the link, not by editing this file:
# #
# ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \ # ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
# ~/.local/share/wallpapers/hyprcosmic/current # ~/.local/share/wallpapers/hyprcosmic/current
sh -c 'until awww query >/dev/null 2>&1; do sleep 0.2; done; exec awww img "$HOME/.local/share/wallpapers/hyprcosmic/current"' #
# A 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. # A terminal, unconditionally, as the way back in.
# #
+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 || :
+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"
+93
View File
@@ -50,6 +50,80 @@ general {
gaps_out = $gap * 2 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 --------------------------------------------------------------- # --- Input ---------------------------------------------------------------
# #
# Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland # Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland
@@ -111,3 +185,22 @@ bind = $mainMod, Return, exec, cosmic-term
# Super+Shift+E is Hyprland's own spelling for "exit". The menu also offers # 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. # lock and suspend, which is why the binding is not named after logout.
bind = $mainMod SHIFT, E, exec, hyprcosmic-powermenu 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
+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.
*/
* {}
+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.
*/
* {}
+139 -1
View File
@@ -50,15 +50,19 @@
"height": 34, "height": 34,
"spacing": 4, "spacing": 4,
"modules-left": ["ext/workspaces", "wlr/taskbar"], "modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
"modules-center": ["mpris", "clock", "privacy"], "modules-center": ["mpris", "clock", "privacy"],
"modules-right": [ "modules-right": [
"systemd-failed-units",
"idle_inhibitor", "idle_inhibitor",
"custom/swaync", "custom/swaync",
"bluetooth", "bluetooth",
"pulseaudio", "pulseaudio",
"network", "network",
"disk",
"temperature", "temperature",
"temperature#gpu",
"custom/fan",
"cpu", "cpu",
"memory", "memory",
"power-profiles-daemon", "power-profiles-daemon",
@@ -67,6 +71,23 @@
"custom/power" "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": { "ext/workspaces": {
"format": "{name}", "format": "{name}",
"on-click": "activate" "on-click": "activate"
@@ -80,6 +101,56 @@
"on-click-middle": "close" "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 // 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. // from a centre module: it takes no space until there is something to say.
// playerctl backs every action here and is installed. // playerctl backs every action here and is installed.
@@ -120,6 +191,23 @@
] ]
}, },
// 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": { "idle_inhibitor": {
"format": "{icon}", "format": "{icon}",
"format-icons": { "format-icons": {
@@ -205,6 +293,19 @@
"on-click": "cosmic-settings network" "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 // 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. // each other here, but the hwmon path names the CPU sensor explicitly.
// //
@@ -220,6 +321,43 @@
"tooltip-format": "CPU package: {temperatureC} C" "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": { "cpu": {
"format": "\udb80\udf5b {usage}%", "format": "\udb80\udf5b {usage}%",
"interval": 5 "interval": 5
+139 -1
View File
@@ -50,15 +50,19 @@
"height": 34, "height": 34,
"spacing": 4, "spacing": 4,
"modules-left": ["ext/workspaces", "wlr/taskbar"], "modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
"modules-center": ["mpris", "clock", "privacy"], "modules-center": ["mpris", "clock", "privacy"],
"modules-right": [ "modules-right": [
"systemd-failed-units",
"idle_inhibitor", "idle_inhibitor",
"custom/swaync", "custom/swaync",
"bluetooth", "bluetooth",
"pulseaudio", "pulseaudio",
"network", "network",
"disk",
"temperature", "temperature",
"temperature#gpu",
"custom/fan",
"cpu", "cpu",
"memory", "memory",
"power-profiles-daemon", "power-profiles-daemon",
@@ -67,6 +71,23 @@
"custom/power" "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": { "ext/workspaces": {
"format": "{name}", "format": "{name}",
"on-click": "activate" "on-click": "activate"
@@ -80,6 +101,56 @@
"on-click-middle": "close" "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 // 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. // from a centre module: it takes no space until there is something to say.
// playerctl backs every action here and is installed. // playerctl backs every action here and is installed.
@@ -120,6 +191,23 @@
] ]
}, },
// 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": { "idle_inhibitor": {
"format": "{icon}", "format": "{icon}",
"format-icons": { "format-icons": {
@@ -205,6 +293,19 @@
"on-click": "cosmic-settings network" "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 // 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. // each other here, but the hwmon path names the CPU sensor explicitly.
// //
@@ -220,6 +321,43 @@
"tooltip-format": "CPU package: {temperatureC} C" "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": { "cpu": {
"format": "@@CPU@@ {usage}%", "format": "@@CPU@@ {usage}%",
"interval": 5 "interval": 5
+6
View File
@@ -55,6 +55,12 @@ ICONS = {
"BAT_80": 0xF0081, "BAT_80": 0xF0081,
"BAT_90": 0xF0082, "BAT_90": 0xF0082,
"BAT_100": 0xF0079, "BAT_100": 0xF0079,
"WINDOW": 0xF2D0,
"ALERT": 0xF0026,
"DISK": 0xF02CA,
"GPU": 0xF08AE,
"FAN": 0xF0210,
"KEYBOARD": 0xF030C,
} }
+34
View File
@@ -77,15 +77,20 @@ window#waybar {
/* Every status module gets the same pill. Listed one per line because waybar /* 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 * 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. */ * not guessable from the config and are worth being able to read down. */
#custom-keybinds,
#window,
#mpris, #mpris,
#clock, #clock,
#privacy, #privacy,
#systemd-failed-units,
#idle_inhibitor, #idle_inhibitor,
#custom-swaync, #custom-swaync,
#bluetooth, #bluetooth,
#pulseaudio, #pulseaudio,
#network, #network,
#disk,
#temperature, #temperature,
#custom-fan,
#cpu, #cpu,
#memory, #memory,
#power-profiles-daemon, #power-profiles-daemon,
@@ -106,6 +111,12 @@ window#waybar {
color: @critical; 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 { #clock {
font-weight: bold; font-weight: bold;
} }
@@ -137,12 +148,35 @@ window#waybar {
color: @warning; 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 /* States. These recolour the text inside the pill rather than the pill, so a
* warning cannot make a module unreadable against its own background. */ * warning cannot make a module unreadable against its own background. */
#battery.warning { color: @warning; } #battery.warning { color: @warning; }
#battery.critical { color: @critical; } #battery.critical { color: @critical; }
#temperature.critical { color: @critical; } #temperature.critical { color: @critical; }
#network.disconnected { 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; } #pulseaudio.muted { color: @muted; }
#idle_inhibitor.activated { color: @accent; } #idle_inhibitor.activated { color: @accent; }
#custom-swaync.notification { color: @accent; } #custom-swaync.notification { color: @accent; }
+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.
*/
+45
View File
@@ -8,6 +8,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.1" version = "2.13.1"
@@ -29,6 +38,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"flate2", "flate2",
"notify", "notify",
"regex",
"ron", "ron",
"serde", "serde",
"tar", "tar",
@@ -158,6 +168,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -237,6 +253,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "ron" name = "ron"
version = "0.12.2" version = "0.12.2"
+4
View File
@@ -8,6 +8,10 @@ description = "Compiles a single Hyprland-idiom config file into the cosmic-conf
[dependencies] [dependencies]
flate2 = "1.1.9" flate2 = "1.1.9"
notify = "8.2.0" 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" ron = "0.12"
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
tar = "0.4.46" tar = "0.4.46"
+1 -1
View File
@@ -630,7 +630,7 @@ fn render_local_rasi(icon_theme: Option<&str>, wallpaper_link: Option<&Path>) ->
let mut out = String::from( let mut out = String::from(
r#"/* Per-machine launcher settings for HyprCosmic. r#"/* Per-machine launcher settings for HyprCosmic.
* *
* Generated by `cosmic-conf import-theme --assets`. It is the last of the four * Generated by `hyprcosmic-conf import-theme --assets`. It is the last of the four
* imports in config.rasi, so anything here wins; it is also overwritten by the * 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. * next import run with --overwrite, so keep hand edits somewhere else.
* *
+2 -2
View File
@@ -272,9 +272,9 @@ fn render_conf(
notes: &[Note], notes: &[Note],
) -> String { ) -> String {
let mut out = format!( let mut out = format!(
"# Generated by `cosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\ "# 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\ # Edit freely — this file is the source of truth; cosmic-settings changes\n\
# are overwritten on the next `cosmic-conf apply`.\n" # are overwritten on the next `hyprcosmic-conf apply`.\n"
); );
let dropped: Vec<&Note> = notes let dropped: Vec<&Note> = notes
+4
View File
@@ -16,12 +16,16 @@ pub mod parser;
pub mod resolve; pub mod resolve;
pub mod schema; pub mod schema;
pub mod watch; pub mod watch;
pub mod windowrule;
pub mod workspace;
pub use bind::{parse_bind, Bind}; pub use bind::{parse_bind, Bind};
pub use emit::{EmitError, Emitter, Planned}; pub use emit::{EmitError, Emitter, Planned};
pub use import::{import_hypr_theme, render_report, Import}; pub use import::{import_hypr_theme, render_report, Import};
pub use parser::{parse, Ast, ParseError, Span}; pub use parser::{parse, Ast, ParseError, Span};
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind}; 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. /// Render a diagnostic against source text, cargo-style.
pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String { pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String {
+8 -4
View File
@@ -7,13 +7,17 @@ use std::process::ExitCode;
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch}; 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 = "\ const USAGE: &str = "\
cosmic-conf — compile cosmic.conf into the cosmic-config tree hyprcosmic-conf — compile cosmic.conf into the cosmic-config tree
USAGE: USAGE:
cosmic-conf apply [--diff] [--config <path>] hyprcosmic-conf apply [--diff] [--config <path>]
cosmic-conf watch [--config <path>] hyprcosmic-conf watch [--config <path>]
cosmic-conf import-theme <hypr.theme> [--out <path>] [--report] hyprcosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
[--assets [--source <dir>] [--overwrite] [--dry-run]] [--assets [--source <dir>] [--overwrite] [--dry-run]]
COMMANDS: COMMANDS:
+258 -3
View File
@@ -15,6 +15,8 @@ use std::collections::BTreeMap;
use crate::bind; use crate::bind;
use crate::parser::{Ast, Item, Span, Spanned}; use crate::parser::{Ast, Item, Span, Spanned};
use crate::schema::{self, Entry, Range, Target, Ty}; use crate::schema::{self, Entry, Range, Target, Ty};
use crate::windowrule;
use crate::workspace;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Value { pub enum Value {
@@ -268,12 +270,61 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new(); let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
let mut whole: BTreeMap<TargetKey, (Value, Span)> = BTreeMap::new(); let mut whole: BTreeMap<TargetKey, (Value, Span)> = BTreeMap::new();
// `bind` is the one repeatable key in the language: many lines fold into a // `bind`, `workspace` and `windowrule` are the repeatable keys in the
// single map rather than the last one winning, so it cannot go through the // language: many lines fold into a single value rather than the last one
// schema, which is built around one conf key naming one value. // winning, so none of them can go through the schema, which is built around
// one conf key naming one value.
let mut binds: Vec<(bind::Bind, Span)> = Vec::new(); let mut binds: Vec<(bind::Bind, Span)> = Vec::new();
let mut workspaces: Vec<(workspace::WorkspaceDecl, Span)> = Vec::new();
let mut window_rules: Vec<windowrule::WindowRuleDecl> = Vec::new();
for (conf, raw_value, key_span) in &flat { for (conf, raw_value, key_span) in &flat {
// `windowrulev2` was Hyprland's name for this syntax before it became
// the only one; configs in the wild are still full of it.
if conf == "windowrule" || conf == "windowrulev2" {
let expanded = expand_vars(&raw_value.value, &vars);
// Not deduplicated: two rules can differ only in their title and
// both be wanted, and the compositor takes the first that matches,
// so the order they were written in is the whole semantics.
match windowrule::parse_window_rule(&expanded, raw_value.span) {
Ok(r) => window_rules.push(r),
Err(e) => diags.push(Diagnostic {
message: e.message,
span: e.span,
help: e.help,
}),
}
continue;
}
if conf == "workspace" {
let expanded = expand_vars(&raw_value.value, &vars);
match workspace::parse_workspace(&expanded, raw_value.span) {
Ok(w) => {
if let Some((_, prev_span)) =
workspaces.iter().find(|(o, _)| o.index == w.index)
{
diags.push(Diagnostic {
message: format!("workspace {} is already declared", w.index),
span: raw_value.span,
help: Some(format!(
"the earlier declaration is on line {}",
prev_span.line
)),
});
continue;
}
workspaces.push((w, raw_value.span));
}
Err(e) => diags.push(Diagnostic {
message: e.message,
span: e.span,
help: e.help,
}),
}
continue;
}
if conf == "bind" { if conf == "bind" {
let expanded = expand_vars(&raw_value.value, &vars); let expanded = expand_vars(&raw_value.value, &vars);
match bind::parse_bind(&expanded, raw_value.span) { match bind::parse_bind(&expanded, raw_value.span) {
@@ -339,6 +390,19 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
return Err(diags); return Err(diags);
} }
// A pinned workspace carries its own `tiling_enabled`, so one that did not
// say has to inherit the session default rather than default to off --
// otherwise declaring workspaces would quietly undo `general:autotile`.
// Read before `whole` is consumed below.
let default_tiling = whole
.get(&TargetKey {
component: "com.system76.CosmicComp".into(),
version: 1,
key: "autotile".into(),
})
.map(|(v, _)| *v == Value::Bool(true))
.unwrap_or(false);
let mut writes: Vec<Write> = whole let mut writes: Vec<Write> = whole
.into_iter() .into_iter()
.map(|(target, (v, _))| Write { .map(|(target, (v, _))| Write {
@@ -367,6 +431,40 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
}); });
} }
if !workspaces.is_empty() {
let rendered = workspace::render(
&workspaces.iter().map(|(w, _)| w.clone()).collect::<Vec<_>>(),
default_tiling,
);
writes.push(Write {
// `Workspaces::add_output` drains this into the first output that
// appears (cosmic-comp `shell/mod.rs`), and `from_pinned` sets
// `pinned: true`, which is what stops `can_auto_remove` collecting
// the workspace the moment its last window closes.
target: TargetKey {
component: "com.system76.CosmicComp".into(),
version: 1,
key: "pinned_workspaces".into(),
},
kind: WriteKind::Verbatim(rendered),
});
}
if !window_rules.is_empty() {
let rendered = windowrule::render(&window_rules);
writes.push(Write {
// Read live: cosmic-comp's config watcher has a `window_rules` arm,
// so an edit applies to the next window that opens. Unlike
// `pinned_workspaces`, which waits for the next login.
target: TargetKey {
component: "com.system76.CosmicComp".into(),
version: 1,
key: "window_rules".into(),
},
kind: WriteKind::Verbatim(rendered),
});
}
writes.sort_by(|a, b| a.target.cmp(&b.target)); writes.sort_by(|a, b| a.target.cmp(&b.target));
Ok(Resolved { writes }) Ok(Resolved { writes })
} }
@@ -510,6 +608,163 @@ mod tests {
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}"); assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
} }
#[test]
fn workspaces_fold_into_one_write_against_the_comp_pinned_workspaces_key() {
let r = resolved("workspace = 1, name:term\nworkspace = 2, name:web\n");
let w: Vec<_> = r
.writes
.iter()
.filter(|w| w.target.key == "pinned_workspaces")
.collect();
assert_eq!(w.len(), 1, "every workspace belongs to one list");
assert_eq!(w[0].target.component, "com.system76.CosmicComp");
assert_eq!(w[0].target.version, 1);
let WriteKind::Verbatim(ron) = &w[0].kind else {
panic!("expected verbatim RON, got {:?}", w[0].kind);
};
assert!(ron.contains(r#"name: Some("term")"#), "{ron}");
assert!(ron.contains(r#"name: Some("web")"#), "{ron}");
}
/// Without this the pinned workspaces would come back floating for a user
/// whose whole reason for editing the file was `autotile = true`.
#[test]
fn a_workspace_that_did_not_say_inherits_general_autotile() {
let r = resolved("general {\n autotile = true\n}\nworkspace = 1\n");
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
else {
panic!("expected verbatim RON");
};
assert!(ron.contains("tiling_enabled: true"), "{ron}");
let r = resolved("general {\n autotile = false\n}\nworkspace = 1\n");
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
else {
panic!("expected verbatim RON");
};
assert!(ron.contains("tiling_enabled: false"), "{ron}");
}
#[test]
fn declaring_the_same_workspace_twice_is_an_error_not_a_silent_overwrite() {
let d = errors("workspace = 2, name:web\nworkspace = 2, name:mail\n");
assert_eq!(d.len(), 1);
assert!(
d[0].message.contains("already declared"),
"{}",
d[0].message
);
assert!(
d[0].help.as_ref().unwrap().contains("line 1"),
"{:?}",
d[0].help
);
}
/// Symmetric with `no_binds_means_the_shortcuts_file_is_left_alone`: writing
/// an empty list would unpin the workspaces of anyone whose cosmic.conf
/// simply does not mention them.
#[test]
fn no_workspaces_means_the_pinned_list_is_left_alone() {
let r = resolved("general {\n gaps_in = 4\n}\n");
assert!(r.writes.iter().all(|w| w.target.key != "pinned_workspaces"));
}
#[test]
fn a_workspace_expands_variables() {
let r = resolved("$browser = web\nworkspace = 1, name:$browser\n");
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
else {
panic!("expected verbatim RON");
};
assert!(ron.contains(r#"name: Some("web")"#), "{ron}");
}
#[test]
fn a_bad_workspace_is_reported_with_the_rest_of_the_file() {
let d = errors("workspace = 0\ngeneral {\n gaps_inn = 8\n}\n");
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
}
#[test]
fn window_rules_fold_into_one_write_against_the_comp_window_rules_key() {
let r = resolved(
"windowrule = workspace name:web, class:^(vivaldi)$\n\
windowrule = workspace 1, class:^(kitty)$\n",
);
let w: Vec<_> = r
.writes
.iter()
.filter(|w| w.target.key == "window_rules")
.collect();
assert_eq!(w.len(), 1, "every rule belongs to one list");
assert_eq!(w[0].target.component, "com.system76.CosmicComp");
assert_eq!(w[0].target.version, 1);
let WriteKind::Verbatim(ron) = &w[0].kind else {
panic!("expected verbatim RON, got {:?}", w[0].kind);
};
assert!(ron.contains(r#"workspace: Name("web")"#), "{ron}");
assert!(ron.contains("workspace: Index(1)"), "{ron}");
}
/// The compositor takes the first rule that matches, so a file that reads
/// top to bottom has to be emitted top to bottom.
#[test]
fn window_rules_keep_the_order_they_were_written_in() {
let r = resolved(
"windowrule = workspace 1, class:^(a)$\n\
windowrule = workspace 2, class:^(b)$\n\
windowrule = workspace 3, class:^(c)$\n",
);
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
panic!("expected verbatim RON");
};
let seen: Vec<&str> = ron
.lines()
.filter(|l| l.contains("app_id:"))
.map(|l| l.trim())
.collect();
assert_eq!(seen.len(), 3);
assert!(seen[0].contains("^(a)$"), "{ron}");
assert!(seen[1].contains("^(b)$"), "{ron}");
assert!(seen[2].contains("^(c)$"), "{ron}");
}
/// The v2 spelling is what configs in the wild are written with.
#[test]
fn windowrulev2_is_the_same_key() {
let r = resolved("windowrulev2 = workspace 2, class:^(firefox)$\n");
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
panic!("expected verbatim RON");
};
assert!(ron.contains("workspace: Index(2)"), "{ron}");
}
/// Symmetric with the bind and workspace cases: writing an empty list would
/// be a change for someone whose cosmic.conf never mentions window rules.
#[test]
fn no_window_rules_means_the_list_is_left_alone() {
let r = resolved("general {\n gaps_in = 4\n}\n");
assert!(r.writes.iter().all(|w| w.target.key != "window_rules"));
}
#[test]
fn a_window_rule_expands_variables() {
let r = resolved("$browser = vivaldi\nwindowrule = workspace 2, class:^($browser)$\n");
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
panic!("expected verbatim RON");
};
assert!(ron.contains("^(vivaldi)$"), "{ron}");
}
#[test]
fn a_bad_window_rule_is_reported_with_the_rest_of_the_file() {
let d = errors("windowrule = float, class:foo\ngeneral {\n gaps_inn = 8\n}\n");
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
}
fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind { fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind {
&r.writes &r.writes
.iter() .iter()
+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}");
}
}
+27 -20
View File
@@ -19,10 +19,14 @@ arch=('x86_64')
url="https://github.com/outbackdingo/hyprcosmic" url="https://github.com/outbackdingo/hyprcosmic"
license=('GPL-3.0-only') license=('GPL-3.0-only')
# The HyDE shell. Without these the session starts to a blank screen: no bar, # cosmic-session is COSMIC itself, which this runs on rather than replaces: one
# no launcher, no wallpaper. # entry pulls the whole desktop, and cosmic-settings, cosmic-osd, the portal and
depends=('waybar' 'rofi-wayland' 'wayland' 'libxkbcommon' 'libinput' 'seatd' # the rest are taken from the repositories at the version they were tested at.
'mesa' 'pixman' 'libdisplay-info' 'systemd-libs') #
# 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 # 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 # depends without making the package uninstallable for anyone who has not built
@@ -32,29 +36,32 @@ optdepends=('awww: wallpaper daemon, required for HyDE theme wallpapers'
'ttf-nerd-fonts-symbols: glyphs the waybar config draws with' 'ttf-nerd-fonts-symbols: glyphs the waybar config draws with'
'qt5ct: Qt application theming to match the GTK theme') 'qt5ct: Qt application theming to match the GTK theme')
# What "complete replacement" means in packaging terms. Every path this writes # No conflicts and no provides, deliberately. This installs beside COSMIC: its
# under /usr/bin is one cosmic-comp and cosmic-session also own, so the two # binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
# cannot coexist -- which is correct, they are two builds of the same programs. # 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
# conflicts without replaces/provides-driven auto-removal: pacman stops and # without erasing the desktop it forked.
# names the conflict rather than quietly removing the desktop the machine is
# currently running.
conflicts=('cosmic-comp' 'cosmic-session')
provides=("cosmic-comp=$pkgver" "cosmic-session=$pkgver")
options=('!strip' '!debug') options=('!strip' '!debug')
package() { package() {
# Set by make-packages.sh. Failing loudly here beats producing an empty # Points at a tree `just install` has already staged. Failing loudly here
# package, which is what a bare `cp -a "$unset/."` would do. # 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 if [ -z "$HYPRCOSMIC_STAGEDIR" ] || [ ! -d "$HYPRCOSMIC_STAGEDIR/usr" ]; then
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see tools/make-packages.sh" >&2 echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see .github/workflows/packages.yml" >&2
return 1 return 1
fi fi
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/" cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
# The two files a broken install shows up in first: a bad Exec line puts an # No desktop-file-validate on the session entry. It rejects DesktopNames,
# entry on the greeter's menu that fails silently when it is chosen. # the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/hyprcosmic.desktop" # Desktop Entry Specification registers keys for application launchers and
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/cosmic.desktop" # 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.
} }
+7 -7
View File
@@ -9,16 +9,13 @@ Installed-Size: @INSTALLED_SIZE@
Depends: @SHLIB_DEPENDS@ Depends: @SHLIB_DEPENDS@
Recommends: waybar, rofi Recommends: waybar, rofi
Suggests: fonts-hack-ttf, qt5ct Suggests: fonts-hack-ttf, qt5ct
Conflicts: cosmic-comp, cosmic-session
Provides: cosmic-comp, cosmic-session
Replaces: cosmic-comp, cosmic-session
Description: COSMIC configured in Hyprland's idiom, with a HyDE shell Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
Hyprland's idiom and wears a HyDE-style shell. Hyprland's idiom and wears a HyDE-style shell.
. .
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind = A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
lines and $variables -- is compiled into COSMIC's own configuration tree by lines and $variables -- is compiled into COSMIC's own configuration tree by
cosmic-conf. The file is the source of truth: keys it names are applied at 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 every login over whatever the settings UI last stored, and keys it does not
name are left alone. name are left alone.
. .
@@ -27,6 +24,9 @@ Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
COSMIC's, with a Hyprland-compatible IPC socket so that HyDE's scripts and COSMIC's, with a Hyprland-compatible IPC socket so that HyDE's scripts and
waybar's hyprland modules work unmodified. waybar's hyprland modules work unmodified.
. .
This package replaces the distribution's COSMIC. It installs both session This package carries the whole desktop, which on Debian it has to: COSMIC is
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one, not packaged there, in any suite, so there is nothing to depend on and nothing
both served by these binaries. 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.
+79 -35
View File
@@ -20,14 +20,19 @@
# it on the release you intend to install it on. The workflow does that by # 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. # running the whole job inside a container of the target distribution.
# #
# WHY IT IS ONE PACKAGE AND NOT TWENTY-SEVEN # 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 # Fedora splits COSMIC into a package per component, which is right for a
# distribution tracking upstream. This is a fork that replaces the desktop as a # distribution tracking upstream. This fork changes three of them -- the
# unit: the compositor, the session and the config compiler are versioned and # compositor, the session and the config compiler it adds -- and they are
# tested together, and there is no supported combination in which you take the # versioned and tested together, so one package is an accurate description of
# HyprCosmic cosmic-comp and the distribution's cosmic-session. One package is # what is actually supported.
# 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 %global _hardened_build 1
@@ -45,22 +50,22 @@ License: GPL-3.0-only
URL: https://github.com/outbackdingo/hyprcosmic URL: https://github.com/outbackdingo/hyprcosmic
BuildArch: x86_64 BuildArch: x86_64
# These are what "complete replacement" means in packaging terms. Every path # COSMIC itself, which this runs on rather than replaces.
# this package writes under /usr/bin and /usr/share/cosmic is owned by one of
# these on a stock Fedora, so the two cannot be installed at once -- which is
# correct, because they are two builds of the same programs.
# #
# Conflicts rather than Obsoletes, deliberately. Obsoletes would let a routine # One line pulls the whole desktop, because cosmic-session requires every
# `dnf install hyprcosmic` quietly remove the desktop the machine is currently # component. That is exactly what is wanted: HyprCosmic forks the compositor,
# running. Conflicts stops and says so, and removing the COSMIC packages stays # the session and adds the config compiler, and takes cosmic-settings,
# something a person decides to do rather than something a resolver does on # cosmic-osd, the portal and the rest from the distribution at the version the
# their behalf. # distribution tested them at.
Conflicts: cosmic-comp #
Conflicts: cosmic-session # 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
# What it stands in for, so anything depending on a COSMIC session is satisfied. # it could not be installed: this package's files collided with 25 others in
Provides: cosmic-comp = %{version}-%{release} # rpm's transaction check, and satisfying that by claiming all 25 with Conflicts
Provides: cosmic-session = %{version}-%{release} # 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 # 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 # builds, and without them the session starts to a blank screen with no bar and
@@ -76,8 +81,30 @@ Requires: rofi-wayland
# unsatisfiable dependency is not. # unsatisfiable dependency is not.
Recommends: awww Recommends: awww
# Nerd Font glyphs are most of what the bar draws. # Something for the wallpaper daemon to draw before a theme has been imported.
Recommends: nerd-fonts # 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 %description
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
@@ -85,7 +112,7 @@ Hyprland's idiom and wears a HyDE-style shell.
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind = A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
lines and $variables -- is compiled into COSMIC's own configuration tree by lines and $variables -- is compiled into COSMIC's own configuration tree by
cosmic-conf. The file is the source of truth: keys it names are applied at every 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 login over whatever the settings UI last stored, and keys it does not name are
left alone. left alone.
@@ -94,9 +121,11 @@ 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 with a Hyprland-compatible IPC socket so that HyDE's scripts and waybar's
hyprland modules work unmodified. hyprland modules work unmodified.
This package replaces the distribution's COSMIC. It installs both session This package installs beside the distribution's COSMIC rather than over it. Its
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one, binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
both served by these binaries. 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 %prep
# Nothing to unpack. See the note at the top of this file. # Nothing to unpack. See the note at the top of this file.
@@ -106,11 +135,16 @@ test -n "%{stagedir}" || { echo "define stagedir: see .github/workflows/packages
test -d "%{stagedir}/usr" || { echo "%{stagedir}/usr missing; run just install first" >&2; exit 1; } test -d "%{stagedir}/usr" || { echo "%{stagedir}/usr missing; run just install first" >&2; exit 1; }
cp -a "%{stagedir}/." "%{buildroot}/" cp -a "%{stagedir}/." "%{buildroot}/"
# The desktop entries are the two files a broken install shows up in first, so # The session entry is checked by the workflow against the staged tree, not
# they are validated rather than assumed. A .desktop with a bad Exec line puts # with desktop-file-validate here. desktop-file-validate rejects DesktopNames,
# an entry on the greeter's menu that fails silently when chosen. # the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/hyprcosmic.desktop" # Desktop Entry Specification registers keys for application launchers and this
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/cosmic.desktop" # 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. # 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 # A hand-maintained list across 27 components would be wrong within a week, and
@@ -119,5 +153,15 @@ desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/cosmic.desktop"
%files -f %{filelist} %files -f %{filelist}
%changelog %changelog
* Sun Aug 10 2026 dingo <[email protected]> - 0.1.0-1 * Wed Aug 12 2026 dingo <[email protected]> - 0.1.1-1
- First package of the fork: COSMIC replaced as a unit, HyDE shell, cosmic-conf. - 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.
+46 -16
View File
@@ -76,6 +76,9 @@ SHARED=(
"config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644" "config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644"
"config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644" "config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644"
"config/bin/hyprcosmic-powermenu:bin/hyprcosmic-powermenu:755" "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 # The session entry point. Kept apart from SHARED because it is versioned in the
@@ -95,31 +98,57 @@ SOURCES=(
"config/waybar/generate-config.py" # the generator, and the icon table "config/waybar/generate-config.py" # the generator, and the icon table
) )
# Files under config/ that are deliberately NOT installed here, each with the # Per-user files. These cannot live in share/hyprcosmic with the rest: style.css
# thing that does install it. This list is not decoration: the audit below # and config.rasi reach their theme through relative @imports, which resolve
# refuses to run unless every file under config/ appears in exactly one of the # against the importing file, so the importer has to sit in the same per-user
# three lists, so adding a file forces a decision about where it belongs instead # directory as the theme it picks up.
# of letting it be quietly left out of all of them. #
PER_USER=( # They are still installed, as a skeleton. start-hyprcosmic copies anything
"config/autostart" # ~/.config/hyprcosmic/autostart, by hand # missing out of share/hyprcosmic/skel into $XDG_CONFIG_HOME when a session
"config/cosmic.conf" # ~/.config/hyprcosmic/cosmic.conf, by hand # starts, and never overwrites. Destinations below therefore mirror the layout
"config/waybar/style.css" # ~/.config/hyprcosmic/waybar/style.css; @imports a sibling theme.css # under ~/.config exactly -- skel/hyprcosmic/autostart becomes
"config/rofi/config.rasi" # ~/.config/rofi/config.rasi, by `cosmic-conf import-theme --assets` # ~/.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() { audit_config_tree() {
local f rel known=" ${PER_USER[*]} ${SOURCES[*]} " unclassified=() local f rel known=" ${SOURCES[*]} " unclassified=()
# Built with a loop, not `${SHARED[*]%%:*}`: that form strips the suffix # Built with a loop, not `${SHARED[*]%%:*}`: that form strips the suffix
# from the first element only and silently keeps the rest whole. # from the first element only and silently keeps the rest whole.
for f in "${SHARED[@]}"; do known+="${f%%:*} "; done for f in "${SHARED[@]}"; do known+="${f%%:*} "; done
for f in "${SKEL[@]}"; do known+="${f%%:*} "; done
while IFS= read -r -d '' f; do while IFS= read -r -d '' f; do
rel="${f#"$REPO"/}" rel="${f#"$REPO"/}"
[[ "$known" == *" $rel "* ]] || unclassified+=("$rel") [[ "$known" == *" $rel "* ]] || unclassified+=("$rel")
done < <(find "$REPO/config" -type f -print0 | sort -z) # 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 or per-user: ${unclassified[*]} ((${#unclassified[@]} == 0)) || die "not listed as shared, skeleton or generator input: ${unclassified[*]}
Add each to SHARED or PER_USER in $(basename "${BASH_SOURCE[0]}") and say which." 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 # The prefix is only half honoured, and pretending otherwise would be worse than
@@ -130,11 +159,12 @@ audit_config_tree() {
check_prefix_assumptions() { check_prefix_assumptions() {
[[ "$PREFIX" == /usr ]] && return 0 [[ "$PREFIX" == /usr ]] && return 0
local hits local hits
hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ 2>/dev/null | sort | tr '\n' ' ')" hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ cosmic-session/data/ 2>/dev/null | sort | tr '\n' ' ')"
[[ -z "$hits" ]] && return 0 [[ -z "$hits" ]] && return 0
warn "PREFIX=$PREFIX, but these name /usr/share/hyprcosmic literally and cannot interpolate it:" warn "PREFIX=$PREFIX, but these name /usr/share/hyprcosmic literally and cannot interpolate it:"
warn " $hits" warn " $hits"
warn "they will keep reading /usr/share unless you edit them; rofi will show a parse error if it is empty" 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 # Fail before touching anything rather than half way through. The nearest
@@ -190,7 +220,7 @@ handle() {
audit_config_tree audit_config_tree
[[ "$MODE" == install ]] && check_prefix_assumptions [[ "$MODE" == install ]] && check_prefix_assumptions
targets=("${SHARED[@]}") targets=("${SHARED[@]}" "${SKEL[@]}")
if ((WITH_SESSION)); then if ((WITH_SESSION)); then
if [[ -d "$REPO/cosmic-session/data" ]]; then if [[ -d "$REPO/cosmic-session/data" ]]; then
targets+=("${SESSION[@]}") targets+=("${SESSION[@]}")