Compare commits

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