mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80566768c | ||
|
|
06f974f09f | ||
|
|
e006e95e55 | ||
|
|
111f3ade7b | ||
|
|
0e6dcdc606 | ||
|
|
28042b9879 | ||
|
|
5bb057cb71 | ||
|
|
43507504f4 | ||
|
|
49d5f8b20e | ||
|
|
fc8c2a727d | ||
|
|
5f521529a8 | ||
|
|
7df465ef26 | ||
|
|
a397334242 | ||
|
|
4bedb04f77 | ||
|
|
e65a9722d9 | ||
|
|
d2b091fa8b | ||
|
|
620941e18b | ||
|
|
63610d57df | ||
|
|
84a5eac79f | ||
|
|
31ce3a51c4 | ||
|
|
c6150755d2 | ||
|
|
c0ae1350f3 | ||
|
|
98e75ecdc8 | ||
|
|
6f9696e791 | ||
|
|
3c1a092e51 | ||
|
|
4ba11d1bb5 | ||
|
|
08028e6f2c | ||
|
|
178dfbec1a | ||
|
|
563fdc7330 | ||
|
|
b8b5ce546d | ||
|
|
b89e4be10a | ||
|
|
bae7c5b0ff | ||
|
|
316fb843c3 | ||
|
|
362f324755 | ||
|
|
1d3252aabd | ||
|
|
50e6c1948c | ||
|
|
9933ff2415 | ||
|
|
cd99893b33 | ||
|
|
a9400d0550 | ||
|
|
272ec0c5d2 | ||
|
|
a32596216a | ||
|
|
e46514cbab | ||
|
|
10c5cc73d4 | ||
|
|
19927bc00b | ||
|
|
214a77ea18 | ||
|
|
8d21c0d084 | ||
|
|
e56ffe8465 | ||
|
|
1d1909baaf | ||
|
|
dee24ac30a | ||
|
|
09f90f65de | ||
|
|
39008709e3 | ||
|
|
0ce7ec30ac | ||
|
|
652b4c45f1 | ||
|
|
a483257d31 | ||
|
|
6eceadf650 | ||
|
|
0098ab4d8f | ||
|
|
3e34c8e746 | ||
|
|
0d128858ad | ||
|
|
f846fcb546 | ||
|
|
43bcd5266f | ||
|
|
a21edb170b | ||
|
|
ed1607856b | ||
|
|
4412bb00d7 | ||
|
|
6169da68ca | ||
|
|
d103b64024 | ||
|
|
9af2199729 | ||
|
|
9e8ac99bcb | ||
|
|
efe2c51b53 | ||
|
|
9b0e61c54b | ||
|
|
f5fa41194e | ||
|
|
f9abdc105d | ||
|
|
dee71b6f25 | ||
|
|
d955da69d4 | ||
|
|
5dcb211692 | ||
|
|
e13e24b54e | ||
|
|
dafb7bb1fa | ||
|
|
13ea973991 | ||
|
|
8722d5507c | ||
|
|
e4edeeca3f | ||
|
|
7106172bcd | ||
|
|
0972aa1396 | ||
|
|
8f98c11b8c | ||
|
|
da3cf5ee1e | ||
|
|
508b7fe8e6 | ||
|
|
39c24b0b4e | ||
|
|
cb3fce4635 | ||
|
|
366bf74f49 | ||
|
|
3c128a6c52 | ||
|
|
ce6bcdb54d | ||
|
|
87391a426f | ||
|
|
8cd00decac | ||
|
|
221a9856f3 | ||
|
|
ea32f6c30b | ||
|
|
102efba5ae | ||
|
|
3ede740ebd | ||
|
|
f15e88c4c6 | ||
|
|
e9a8b4be7f | ||
|
|
6fb8b35cf5 | ||
|
|
39ea8cb129 | ||
|
|
759fb8efea | ||
|
|
cba864f0cb | ||
|
|
b027c4fc10 | ||
|
|
b8d349fd33 | ||
|
|
d539f2b331 | ||
|
|
a07aecf6a2 | ||
|
|
534c1c132f | ||
|
|
547479a1c5 | ||
|
|
5c1ab6d9eb | ||
|
|
55489ceffb | ||
|
|
efe2106ca2 | ||
|
|
b6011670e3 | ||
|
|
c4cd5a4950 | ||
|
|
9e13a3e47a | ||
|
|
b50a37bedb | ||
|
|
f9c45ca65b | ||
|
|
f55b95f67e | ||
|
|
cdc45ee666 | ||
|
|
8e7e5bbc0b | ||
|
|
be97a211e1 | ||
|
|
ba96994e7d | ||
|
|
f8a0f29675 | ||
|
|
85a48f0f53 | ||
|
|
5c4fa4287e | ||
|
|
afcb876be2 | ||
|
|
5ed56ee132 | ||
|
|
2e7a373ff2 | ||
|
|
8145b20798 |
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. Pop!_OS 24.04]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,61 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: cosmic-applets
|
||||
url: https://github.com/pop-os/cosmic-applets/issues
|
||||
about: Concern all the applets living in the dock or the panel.
|
||||
|
||||
- name: cosmic-comp
|
||||
url: https://github.com/pop-os/cosmic-comp/issues
|
||||
about: Responsible for composing your windows on the screen. If you see issues with the tilling features or your multiple displays, this is likely the place to go.
|
||||
|
||||
- name: cosmic-edit
|
||||
url: https://github.com/pop-os/cosmic-edit/issues
|
||||
about: This is the issue tracker for COSMIC Text Editor. If you see issues with the text editor, report them here.
|
||||
|
||||
- name: cosmic-files
|
||||
url: https://github.com/pop-os/cosmic-files/issues
|
||||
about: This is the issue tracker for COSMIC Files. If you see issues while navigating files, this is the place to go.
|
||||
|
||||
- name: cosmic-settings
|
||||
url: https://github.com/pop-os/cosmic-settings/issues
|
||||
about: This is the issue tracker for COSMIC Settings. If you see issues while changing settings, this is the place to go.
|
||||
|
||||
- name: cosmic-store
|
||||
url: https://github.com/pop-os/cosmic-store/issues
|
||||
about: This is the issue tracker for COSMIC Store. If you see issues with the app store, report them here.
|
||||
|
||||
- name: cosmic-term
|
||||
url: https://github.com/pop-os/cosmic-term/issues
|
||||
about: This is the issue tracker for COSMIC Terminal. If you see issues with the terminal, report them here.
|
||||
|
||||
- name: cosmic-panel
|
||||
url: https://github.com/pop-os/cosmic-panel/issues
|
||||
about: Contains the code to manage the dock and panel applets. You can see it as the compositor of applets. It is also responsible to draw the panel and dock at the right place (bottom, left, ect..).
|
||||
|
||||
- name: cosmic-screenshot
|
||||
url: https://github.com/pop-os/cosmic-screenshot/issues
|
||||
about: This is the tool that help you take screenshots.
|
||||
|
||||
- name: cosmic-workspaces-epoch
|
||||
url: https://github.com/pop-os/cosmic-workspaces-epoch/issues
|
||||
about: Responsible for the workspace overview. If you have an issue with workspaces, this is the place to go.
|
||||
|
||||
- name: cosmic-greeter
|
||||
url: https://github.com/pop-os/cosmic-greeter/issues
|
||||
about: When your session is locked, or not opened yet, this is the window that will be shown. Basically the login screen.
|
||||
|
||||
- name: cosmic-osd
|
||||
url: https://github.com/pop-os/cosmic-osd/issues
|
||||
about: Cosmic on screen display. This is for example, the audio notification when you change the volume, or the brightness one.
|
||||
|
||||
- name: cosmic-applibrary
|
||||
url: https://github.com/pop-os/cosmic-applibrary/issues
|
||||
about: It is the application launched with super-a, that let you see what app are in your system, and launch them.
|
||||
|
||||
- name: cosmic-launcher
|
||||
url: https://github.com/pop-os/cosmic-launcher/issues
|
||||
about: Launched with the super key. It lets you search in your application, and display the result in a list.
|
||||
|
||||
- name: cosmic-bg
|
||||
url: https://github.com/pop-os/cosmic-bg/issues
|
||||
about: If you notice issues with the wallpaper, this is the place to go.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,8 @@
|
||||
- [ ] I have disclosed use of any AI generated code in my commit messages.
|
||||
- If you are using an LLM, and do not fully understand the changes it is making to the code base, do not create a PR.
|
||||
- In our experience, AI generated code often results in overly complex code that lacks enough context for a proper fix or feature inclusion. This results in considerably longer code reviews. Due to this, AI authored or partially authored PRs may be closed without comment.
|
||||
- [ ] I understand these changes in full and will be able to respond to review comments.
|
||||
- [ ] My change is accurately described in the commit message.
|
||||
- [ ] My contribution is tested and working as described.
|
||||
- [ ] I have read the [Developer Certificate of Origin](https://developercertificate.org/) and certify my contribution under its conditions.
|
||||
|
||||
@@ -17,13 +17,12 @@ jobs:
|
||||
Test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:latest
|
||||
image: archlinux:base-devel
|
||||
volumes:
|
||||
- /:/host
|
||||
steps:
|
||||
- run: >
|
||||
pacman --noconfirm -Syu
|
||||
base-devel
|
||||
pacman --noconfirm --needed -Syu
|
||||
cargo
|
||||
clang
|
||||
desktop-file-utils
|
||||
@@ -31,6 +30,7 @@ jobs:
|
||||
gtk3
|
||||
gtk4
|
||||
just
|
||||
libdisplay-info
|
||||
libinput
|
||||
libxkbcommon
|
||||
llvm
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
flatpak
|
||||
nasm
|
||||
- run: rm -rf /host/usr/local/lib/android # Free space
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
# Safe directory behavior seems to have issues with `container:`
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# Build and check the parts of HyprCosmic that are not one of the two forks:
|
||||
# cosmic-conf, the shared waybar/rofi assets, and the installer that places them.
|
||||
#
|
||||
# Two jobs with different shapes on purpose. cosmic-conf is compiled code and
|
||||
# gets the same per-distribution matrix the forks do. The assets are text, and
|
||||
# text does not care which distribution it is on -- what matters there is whether
|
||||
# generated files are still in step with their generator, which is a single
|
||||
# question with a single answer.
|
||||
# The name is not "CI", which is upstream's ci.yml. Two workflows sharing a name
|
||||
# share `${{ github.workflow }}`, and the concurrency group below is built from
|
||||
# it -- they would cancel each other on every push, and whichever started second
|
||||
# would be the only one that ever reported.
|
||||
name: HyprCosmic
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
cosmic-conf:
|
||||
name: cosmic-conf (${{ matrix.distro }})
|
||||
runs-on: ubuntu-latest
|
||||
container: ${{ matrix.image }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- distro: fedora
|
||||
image: fedora:latest
|
||||
- distro: debian
|
||||
image: debian:bookworm
|
||||
- distro: arch
|
||||
image: archlinux:latest
|
||||
|
||||
steps:
|
||||
# Before checkout: actions/checkout needs git and these images are bare.
|
||||
- name: Install build dependencies (fedora)
|
||||
if: matrix.distro == 'fedora'
|
||||
run: dnf -y install --setopt=install_weak_deps=False git curl gcc
|
||||
|
||||
- name: Install build dependencies (debian)
|
||||
if: matrix.distro == 'debian'
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
git curl ca-certificates build-essential
|
||||
|
||||
- name: Install build dependencies (arch)
|
||||
if: matrix.distro == 'arch'
|
||||
run: pacman -Syu --noconfirm --needed git curl base-devel
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain stable --profile minimal \
|
||||
--component clippy
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: cosmic-conf
|
||||
key: ${{ matrix.distro }}
|
||||
|
||||
- name: Test
|
||||
working-directory: cosmic-conf
|
||||
run: cargo test --locked
|
||||
|
||||
# Warnings are errors here because the alternative is a build log nobody
|
||||
# reads and a lint that has been failing for six months.
|
||||
- name: Clippy
|
||||
working-directory: cosmic-conf
|
||||
run: cargo clippy --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Build
|
||||
working-directory: cosmic-conf
|
||||
run: cargo build --release --locked
|
||||
|
||||
# The end-to-end question the unit tests cannot ask: does the cosmic.conf
|
||||
# this repository actually ships still parse and resolve? A schema change
|
||||
# that invalidates the shipped config would pass every test in the crate
|
||||
# and break every user on first login.
|
||||
#
|
||||
# HOME is redirected so the resolution reports against an empty config
|
||||
# tree rather than the runner's own. The binary is run directly instead of
|
||||
# through `cargo run`: cargo keys its registry cache on HOME, so moving
|
||||
# HOME would send it off to re-download every dependency into a directory
|
||||
# the cache action does not know about.
|
||||
#
|
||||
# --diff writes nothing, and the last two lines hold it to that.
|
||||
- name: The shipped cosmic.conf still resolves
|
||||
working-directory: cosmic-conf
|
||||
run: |
|
||||
set -eux
|
||||
fake="$RUNNER_TEMP/fakehome"
|
||||
rm -rf "$fake"
|
||||
mkdir -p "$fake/.config"
|
||||
env HOME="$fake" XDG_CONFIG_HOME="$fake/.config" \
|
||||
./target/release/cosmic-conf apply --diff --config ../config/cosmic.conf
|
||||
test -z "$(find "$fake" -type f -print -quit)"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cosmic-conf-${{ matrix.distro }}
|
||||
path: cosmic-conf/target/release/cosmic-conf
|
||||
retention-days: 14
|
||||
|
||||
assets:
|
||||
name: assets and installer
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Not `submodules: recursive`. This repository names 29 of them, 27 of
|
||||
# which are the rest of COSMIC and none of which this job touches -- the
|
||||
# only one needed is cosmic-session, because tools/install-assets.sh also
|
||||
# places the session entry point that lives there. Cloning the other 27 to
|
||||
# lint some CSS would cost several gigabytes per run.
|
||||
#
|
||||
# The script degrades gracefully when that checkout is absent -- it warns
|
||||
# and skips those two files -- so a failure to fetch would quietly reduce
|
||||
# what is covered rather than fail. Hence the assertion after it.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch only the session submodule
|
||||
run: |
|
||||
set -eux
|
||||
git submodule update --init --depth 1 cosmic-session
|
||||
test -f cosmic-session/data/start-hyprcosmic
|
||||
|
||||
# config.jsonc is generated, and the repository's rule is that it is never
|
||||
# hand-edited: every Nerd Font glyph in it comes from a codepoint table in
|
||||
# generate-config.py, because Private Use Area characters are destroyed by
|
||||
# being retyped and indistinguishable in a diff. That rule is currently
|
||||
# enforced by remembering it. This enforces it instead -- regenerate, and
|
||||
# the file must not move.
|
||||
- name: The generated waybar config is in step with its generator
|
||||
run: |
|
||||
set -eux
|
||||
python3 config/waybar/generate-config.py \
|
||||
config/waybar/config.jsonc.in config/waybar/config.jsonc
|
||||
git diff --exit-code -- config/waybar/config.jsonc
|
||||
|
||||
# No PUA character may reach the template or the generator's own source.
|
||||
# The generator refuses to emit non-ASCII, but nothing stopped one being
|
||||
# pasted into its inputs until here.
|
||||
#
|
||||
# Written as an `if` rather than `! grep ...` because grep exits 1 for "no
|
||||
# match" and 2 for "no such file", and negating it would turn a vanished
|
||||
# file into a pass -- the check would quietly stop checking anything.
|
||||
- name: The template and generator stay pure ASCII
|
||||
run: |
|
||||
set -eu
|
||||
for f in config/waybar/config.jsonc.in config/waybar/generate-config.py; do
|
||||
test -f "$f"
|
||||
if LC_ALL=C grep -Pn '[^\x00-\x7F]' "$f"; then
|
||||
echo "$f: non-ASCII above. Glyphs belong in the codepoint table," \
|
||||
"not in the template." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# A login-time script with a syntax error is a black screen with nowhere to
|
||||
# print the reason.
|
||||
# Each script is checked with the shell its shebang names rather than with
|
||||
# bash across the board: hyprcosmic-fan is POSIX sh on purpose, and `bash
|
||||
# -n` would happily accept a bashism that fails on a system where /bin/sh
|
||||
# is dash.
|
||||
- name: Syntax-check the shell scripts
|
||||
run: |
|
||||
set -eux
|
||||
sh -n config/bin/hyprcosmic-fan
|
||||
bash -n config/bin/hyprcosmic-keybinds
|
||||
bash -n config/bin/hyprcosmic-powermenu
|
||||
bash -n tools/install-assets.sh
|
||||
|
||||
# Every script is shellcheck-clean today, so this starts as a ratchet
|
||||
# rather than a backlog. The runner image ships shellcheck; the install is
|
||||
# there so that stopping to be true is a slow step and not a broken job.
|
||||
- name: Shellcheck
|
||||
run: |
|
||||
set -eux
|
||||
command -v shellcheck >/dev/null || {
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck
|
||||
}
|
||||
shellcheck config/bin/hyprcosmic-fan \
|
||||
config/bin/hyprcosmic-keybinds \
|
||||
config/bin/hyprcosmic-powermenu \
|
||||
tools/install-assets.sh
|
||||
|
||||
# A round trip. Installing into a staging root and then asking --check to
|
||||
# confirm it exercises both halves of the script against each other, and
|
||||
# the audit it runs first refuses to proceed at all unless every file under
|
||||
# config/ is classified as shared, per-user or a generator input. That
|
||||
# audit is the real test: it is what stops a new file being silently left
|
||||
# out of the install.
|
||||
- name: Install into a staging root, then verify it
|
||||
run: |
|
||||
set -eux
|
||||
DESTDIR="$PWD/stage" ./tools/install-assets.sh
|
||||
DESTDIR="$PWD/stage" ./tools/install-assets.sh --check
|
||||
find stage -type f -printf '%M %10s %P\n'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hyprcosmic-assets
|
||||
path: stage/
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,556 @@
|
||||
# Build installable HyprCosmic packages for Fedora, Arch and Debian.
|
||||
#
|
||||
# WHY THIS IS A SEPARATE WORKFLOW FROM hyprcosmic.yml
|
||||
# --------------------------------------------------
|
||||
# hyprcosmic.yml beside it answers "does the fork still build and do the assets
|
||||
# still install where they claim to", on every push, in a few minutes. This one
|
||||
# compiles 27 Rust components three times over and takes hours. Sharing a file
|
||||
# would mean either running the slow thing on every push or never running the
|
||||
# fast thing on a tag, and a `if:` guard threaded through a shared matrix to
|
||||
# avoid that is harder to read than two files.
|
||||
#
|
||||
# WHY IT DOES NOT RUN ON EVERY PUSH
|
||||
# ---------------------------------
|
||||
# Three full desktop builds per commit is hours of runner time to produce
|
||||
# artifacts nobody downloads. Tags get packages because that is when a package
|
||||
# means something; workflow_dispatch covers wanting one at any other time.
|
||||
#
|
||||
# WHY THE WHOLE JOB RUNS IN A CONTAINER
|
||||
# -------------------------------------
|
||||
# Nothing here is statically linked, so a package is only valid on the
|
||||
# distribution that built it -- an RPM built on Ubuntu's runner would name
|
||||
# Ubuntu's sonames and refuse to install on Fedora. `container:` puts the
|
||||
# compile, the staging and the package build all inside an image of the target,
|
||||
# so the sonames recorded are the ones that will exist on the machine installing
|
||||
# it. This is also why no step here runs on a developer's workstation: the
|
||||
# distribution being targeted is rarely the one being typed at.
|
||||
name: Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to stamp on the packages'
|
||||
required: false
|
||||
default: '0.1.0'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: ${{ matrix.distro }}
|
||||
runs-on: ubuntu-latest
|
||||
container: ${{ matrix.image }}
|
||||
# GitHub defaults `run:` to `sh -e {0}` inside a container, and on Debian
|
||||
# that is dash. Several steps below use brace expansion and process
|
||||
# substitution, which dash does not have and which bash-invoked-as-sh
|
||||
# disables. Naming bash once here is better than writing POSIX around a
|
||||
# constraint no target actually imposes -- all three images ship bash.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
strategy:
|
||||
# One distribution failing on a package name is worth seeing on its own,
|
||||
# and the other two artifacts are still worth having.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- distro: fedora
|
||||
image: fedora:44
|
||||
- distro: arch
|
||||
image: archlinux:base-devel
|
||||
# trixie rather than the bookworm the other workflows use. bookworm has
|
||||
# no `just` package -- it arrived in trixie -- and its rustc is 1.63,
|
||||
# so `just` would have to be compiled by a toolchain installed before
|
||||
# the thing that installs toolchains. CI pays that to prove the crate
|
||||
# builds on the oldest supported Debian; a package has no such point
|
||||
# to make.
|
||||
- distro: debian
|
||||
image: debian:trixie
|
||||
|
||||
steps:
|
||||
# Before checkout, deliberately: actions/checkout needs git in the image
|
||||
# and these are bare.
|
||||
#
|
||||
# The library lists are the union of Build-Depends across all 27 upstream
|
||||
# components' debian/control files, translated per distribution rather
|
||||
# than trimmed. Derived mechanically rather than assembled by hand, after
|
||||
# a hand-assembled list built for eighteen minutes and then failed on
|
||||
# dav1d -- a dependency of cosmic-bg that nothing in the obvious set names.
|
||||
#
|
||||
# The Fedora and Arch translations were checked against dnf repoquery and
|
||||
# archlinux.org's package API rather than guessed, because a name that
|
||||
# does not exist fails the whole step: it is libdav1d-devel on Fedora, not
|
||||
# dav1d-devel, and ttf-opensans on Arch, not otf-opensans.
|
||||
#
|
||||
# The Fedora list names a set of basic tools the other two get for free.
|
||||
# Fedora's container image is deliberately minimal: no `which` (deprecated
|
||||
# there in favour of `command -v`), no make, no findutils, no tar. Arch's
|
||||
# base-devel and Debian's build-essential plus its essential set include
|
||||
# all of them, which is why this only ever broke on one of the three.
|
||||
#
|
||||
# These are not guesses. The top-level justfile opens with
|
||||
# `make := \`which make\``, and the submodules' own recipes shell out to
|
||||
# find, xargs, tar and sed -- 114 tar invocations and 18 find between
|
||||
# them. Several steps in this workflow use find as well.
|
||||
- name: Install build dependencies (fedora)
|
||||
if: matrix.distro == 'fedora'
|
||||
run: |
|
||||
dnf -y install --setopt=install_weak_deps=False \
|
||||
git curl ca-certificates just \
|
||||
gcc gcc-c++ make which findutils tar gzip sed diffutils \
|
||||
cmake pkgconf-pkg-config nasm lld mold \
|
||||
clang-devel llvm-devel \
|
||||
desktop-file-utils rpm-build intltool ImageMagick open-sans-fonts \
|
||||
dbus-devel expat-devel fontconfig-devel freetype-devel \
|
||||
libinput-devel libseat-devel libxkbcommon-devel \
|
||||
mesa-libgbm-devel mesa-libEGL-devel libglvnd-devel \
|
||||
wayland-devel libdisplay-info-devel libdav1d-devel \
|
||||
pixman-devel cairo-devel pango-devel glib2-devel gtk3-devel gtk4-devel \
|
||||
pipewire-devel pulseaudio-libs-devel \
|
||||
gstreamer1-devel gstreamer1-plugins-base-devel \
|
||||
flatpak-devel systemd-devel libgudev-devel \
|
||||
openssl-devel pam-devel libxml2-devel xkeyboard-config-devel
|
||||
|
||||
# Shorter than the others, and not by omission: Arch ships headers in the
|
||||
# main package rather than splitting a -devel, so `wayland` here is
|
||||
# `wayland-devel` on Fedora.
|
||||
- name: Install build dependencies (arch)
|
||||
if: matrix.distro == 'arch'
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed \
|
||||
git curl just cmake pkgconf nasm lld mold clang llvm \
|
||||
desktop-file-utils sudo intltool imagemagick ttf-opensans \
|
||||
dbus expat fontconfig freetype2 \
|
||||
libinput seatd libxkbcommon \
|
||||
mesa libglvnd wayland libdisplay-info dav1d \
|
||||
pixman cairo pango glib2 gtk3 gtk4 \
|
||||
pipewire libpipewire libpulse gst-plugins-base-libs \
|
||||
flatpak systemd-libs libgudev \
|
||||
openssl pam libxml2 xkeyboard-config
|
||||
|
||||
- name: Install build dependencies (debian)
|
||||
if: matrix.distro == 'debian'
|
||||
run: |
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
git curl ca-certificates just \
|
||||
build-essential cmake pkg-config nasm lld mold \
|
||||
clang libclang-dev llvm-dev \
|
||||
desktop-file-utils dpkg-dev fakeroot file \
|
||||
intltool imagemagick fonts-open-sans \
|
||||
libdbus-1-dev libexpat1-dev libfontconfig-dev libfreetype-dev \
|
||||
libinput-dev libseat-dev libxkbcommon-dev \
|
||||
libgbm-dev libegl-dev libegl1-mesa-dev libgles-dev \
|
||||
libwayland-dev libdisplay-info-dev libdav1d-dev \
|
||||
libpixman-1-dev libcairo2-dev libpango1.0-dev libglib2.0-dev \
|
||||
libgtk-3-dev libgtk-4-dev \
|
||||
libpipewire-0.3-dev libspa-0.2-dev libpulse-dev \
|
||||
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
|
||||
libflatpak-dev libsystemd-dev libudev-dev libgudev-1.0-dev \
|
||||
libssl-dev libpam0g-dev libxml2-dev xkb-data libxcb1-dev
|
||||
|
||||
# submodules: recursive is the whole point -- this repository is 27
|
||||
# components plus the two forks, and a checkout without them builds
|
||||
# nothing.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# stable as the default, not `none`.
|
||||
#
|
||||
# The two fork workflows use --default-toolchain none and let
|
||||
# rust-toolchain.toml decide, which is right there because the checkout
|
||||
# root is the crate and the pin sits in it. Here it does not: the pin is
|
||||
# cosmic-comp/rust-toolchain.toml, one level down, and this job's other 27
|
||||
# components have no pin at all. With `none` there is no default to fall
|
||||
# back to and the first cargo invocation at the repository root fails
|
||||
# before anything is built.
|
||||
#
|
||||
# Naming stable does not weaken the pin. rustup applies a directory-local
|
||||
# rust-toolchain.toml whenever it enters that directory and installs it on
|
||||
# demand, so cosmic-comp still compiles with the 1.93 it asks for while
|
||||
# everything else uses stable.
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
rustup show
|
||||
cargo --version
|
||||
just --version
|
||||
|
||||
# No Swatinem/rust-cache here, unlike the two fork workflows.
|
||||
#
|
||||
# It was tried and it fails on this repository specifically: the action
|
||||
# runs `cargo metadata` at the workspace root to work out what to cache,
|
||||
# and this root is a meta-repository with no Cargo.toml -- it is 29
|
||||
# submodules, each its own crate with its own target/. The action reported
|
||||
# `could not find Cargo.toml in /__w/hyprcosmic/hyprcosmic` and cached
|
||||
# nothing.
|
||||
#
|
||||
# Listing all 29 workspaces would fix the error and create a worse
|
||||
# problem: their target directories come to roughly 17 GB, against a 10 GB
|
||||
# per-repository cache limit, so the jobs would evict each other's entries
|
||||
# every run and pay upload time for the privilege. A cold build is the
|
||||
# honest cost of a workflow that only runs on tags and on demand.
|
||||
- name: Determine version
|
||||
id: ver
|
||||
run: |
|
||||
set -eux
|
||||
# A tag is authoritative; a manual run uses its input; anything else
|
||||
# falls back so the job is still testable from a branch.
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
v="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
v="${{ inputs.version || '0.1.0' }}"
|
||||
fi
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build
|
||||
run: just build
|
||||
|
||||
# prefix=/usr, not the justfile's /usr/local default. Both .desktop files
|
||||
# name an absolute Exec under /usr/bin -- a desktop entry cannot
|
||||
# interpolate a prefix -- so any other prefix stages entries pointing at
|
||||
# paths this step did not write.
|
||||
- name: Stage the install
|
||||
run: just install "$PWD/stage" /usr
|
||||
|
||||
# The three binaries this fork actually changes, under their own names.
|
||||
#
|
||||
# Done on every distribution, because start-hyprcosmic and the session
|
||||
# entry name these paths and there is no reason for those to differ per
|
||||
# distribution. cosmic-session takes the compositor as argv[1], so the
|
||||
# pair is arranged in start-hyprcosmic and needs no source change.
|
||||
- name: Give this fork's binaries their own names
|
||||
run: |
|
||||
set -eux
|
||||
mv stage/usr/bin/cosmic-comp stage/usr/bin/hyprcosmic-comp
|
||||
mv stage/usr/bin/cosmic-session stage/usr/bin/hyprcosmic-session
|
||||
mv stage/usr/bin/cosmic-conf stage/usr/bin/hyprcosmic-conf
|
||||
|
||||
# The stock session entry goes with it, on Debian only.
|
||||
#
|
||||
# start-cosmic execs /usr/bin/cosmic-session, which the rename above just
|
||||
# took away, so cosmic.desktop would sit on the greeter's menu and die
|
||||
# silently when chosen. Everywhere else the reduction below removes both
|
||||
# and the distribution's own cosmic-session package supplies a working
|
||||
# pair; on Debian there is no such package, so they are removed here and
|
||||
# the greeter offers the HyprCosmic entry alone.
|
||||
- name: Drop the stock session entry it can no longer start
|
||||
if: matrix.distro == 'debian'
|
||||
run: |
|
||||
set -eux
|
||||
rm -f stage/usr/bin/start-cosmic
|
||||
rm -f stage/usr/share/wayland-sessions/cosmic.desktop
|
||||
|
||||
# HyprCosmic installs beside COSMIC rather than over it, and this is the
|
||||
# step that makes that true.
|
||||
#
|
||||
# `just install` stages the whole desktop, because it builds the whole
|
||||
# tree. Nearly all of it is byte-identical to what the distribution
|
||||
# already ships, and the parts that are not are file conflicts that stop
|
||||
# the install outright -- 62 of them on a stock Fedora COSMIC, across 25
|
||||
# packages, which rpm reports only in the transaction check, long after
|
||||
# dnf's dependency solving has said the transaction is fine.
|
||||
#
|
||||
# The alternative to reducing the tree is to claim all 25 packages with
|
||||
# Conflicts, which means erasing them to install this, which on Fedora
|
||||
# includes cosmic-greeter -- the display manager. A fork you can only try
|
||||
# by removing the desktop you would fall back to is a fork with no way
|
||||
# back. So everything the distribution already owns is dropped, and the
|
||||
# package depends on the distribution's COSMIC for it.
|
||||
#
|
||||
# Not on Debian, which has no COSMIC to depend on: neither cosmic-session
|
||||
# nor cosmic-comp is packaged there, in any suite. Reducing the tree there
|
||||
# would produce a package whose dependency can never be satisfied, so the
|
||||
# Debian build keeps the whole desktop it just compiled and stands alone.
|
||||
# Revisit when Debian packages COSMIC.
|
||||
- name: Reduce the staged tree to this fork's own files
|
||||
if: matrix.distro != 'debian'
|
||||
run: |
|
||||
set -eux
|
||||
# /usr/share/cosmic goes too, all of it. Those are the defaults the
|
||||
# compositor reads at first run, and this fork carries upstream's
|
||||
# copies unmodified -- every one of them is byte-identical to a file a
|
||||
# distribution package already owns. rpm permits two packages to own
|
||||
# an identical file, so keeping them would install today and then
|
||||
# collide the first time the distribution changed one. They arrive
|
||||
# with the cosmic-comp this package depends on.
|
||||
( cd stage && find . \( -type f -o -type l \) -printf '%P\n' ) |
|
||||
while read -r p; do
|
||||
case $p in
|
||||
usr/bin/hyprcosmic-*|usr/bin/start-hyprcosmic) continue ;;
|
||||
usr/share/hyprcosmic/*) continue ;;
|
||||
usr/share/wayland-sessions/hyprcosmic.desktop) continue ;;
|
||||
esac
|
||||
rm -f "stage/$p"
|
||||
done
|
||||
find stage -type d -empty -delete
|
||||
|
||||
# Worth failing here rather than shipping a package that is missing the
|
||||
# compositor. The negative assertions are the ones that would rot quietly:
|
||||
# nothing may return to the private libexec layout this fork used to
|
||||
# install into, and no cosmic-* name may come back, because either one is
|
||||
# a file conflict that only shows up on a machine that has COSMIC
|
||||
# installed -- which is every machine this is meant for.
|
||||
- name: Assert the staged tree is this fork and nothing else
|
||||
if: matrix.distro != 'debian'
|
||||
run: |
|
||||
set -eux
|
||||
test -x stage/usr/bin/hyprcosmic-comp
|
||||
test -x stage/usr/bin/hyprcosmic-session
|
||||
test -x stage/usr/bin/hyprcosmic-conf
|
||||
test -x stage/usr/bin/start-hyprcosmic
|
||||
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
|
||||
test -d stage/usr/share/hyprcosmic
|
||||
test ! -e stage/usr/libexec/hyprcosmic
|
||||
test ! -e stage/usr/bin/cosmic-comp
|
||||
test ! -e stage/usr/bin/cosmic-session
|
||||
test ! -e stage/usr/share/wayland-sessions/cosmic.desktop
|
||||
test ! -e stage/usr/share/cosmic
|
||||
test -z "$(find stage/usr/bin -mindepth 1 ! -name 'hyprcosmic-*' ! -name 'start-hyprcosmic')"
|
||||
echo "staged files: $(find stage -type f | wc -l)"
|
||||
|
||||
# Debian is not reduced, so the assertion is the opposite one: the package
|
||||
# stands alone there and has to carry a desktop that starts. The renamed
|
||||
# three must be present under their new names, and the components the
|
||||
# session launches must still be in the tree rather than assumed to arrive
|
||||
# from a distribution package that does not exist.
|
||||
- name: Assert the staged tree is a complete desktop
|
||||
if: matrix.distro == 'debian'
|
||||
run: |
|
||||
set -eux
|
||||
test -x stage/usr/bin/hyprcosmic-comp
|
||||
test -x stage/usr/bin/hyprcosmic-session
|
||||
test -x stage/usr/bin/hyprcosmic-conf
|
||||
test -x stage/usr/bin/start-hyprcosmic
|
||||
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
|
||||
test -d stage/usr/share/hyprcosmic
|
||||
test -d stage/usr/share/cosmic
|
||||
test ! -e stage/usr/libexec/hyprcosmic
|
||||
test ! -e stage/usr/bin/cosmic-comp
|
||||
test ! -e stage/usr/bin/cosmic-session
|
||||
test ! -e stage/usr/bin/start-cosmic
|
||||
test ! -e stage/usr/share/wayland-sessions/cosmic.desktop
|
||||
for c in cosmic-settings cosmic-settings-daemon cosmic-osd cosmic-notifications; do
|
||||
test -x "stage/usr/bin/$c" || { echo "missing $c" >&2; exit 1; }
|
||||
done
|
||||
echo "staged files: $(find stage -type f | wc -l)"
|
||||
|
||||
# Checked here, once, rather than in each of the three packaging recipes,
|
||||
# and not with desktop-file-validate.
|
||||
#
|
||||
# desktop-file-validate rejects DesktopNames -- "keys extending the format
|
||||
# should start with X-" -- because the Desktop Entry Specification
|
||||
# registers keys for application launchers, and these are session files.
|
||||
# DesktopNames is what a display manager reads to set XDG_CURRENT_DESKTOP,
|
||||
# so the session needs it. cosmic.desktop is upstream cosmic-session's
|
||||
# file, unchanged here apart from the Exec path, and the copy Fedora ships
|
||||
# as cosmic-session-1.5.0-1.fc44 fails the identical check: the validator
|
||||
# has no entry for the key, and every distribution ships the file anyway.
|
||||
#
|
||||
# What the validator would not have caught is the failure that actually
|
||||
# matters: an Exec naming a binary this package does not install puts an
|
||||
# entry on the greeter's menu that dies silently when chosen. So that is
|
||||
# what is checked, against the tree about to be packaged.
|
||||
- name: Check the session entries
|
||||
run: |
|
||||
set -eu
|
||||
for f in stage/usr/share/wayland-sessions/*.desktop; do
|
||||
echo "== $f"
|
||||
cat "$f"
|
||||
test "$(sed -n 1p "$f")" = '[Desktop Entry]' || {
|
||||
echo "$f: first line is not [Desktop Entry]" >&2; exit 1; }
|
||||
for key in Name Type Exec DesktopNames; do
|
||||
grep -q "^${key}=" "$f" || { echo "$f: no $key=" >&2; exit 1; }
|
||||
done
|
||||
grep -qx 'Type=Application' "$f" || {
|
||||
echo "$f: Type is not Application; a greeter will ignore it" >&2; exit 1; }
|
||||
exec_path=$(sed -n '0,/^Exec=/s/^Exec=//p' "$f" | cut -d' ' -f1)
|
||||
case $exec_path in
|
||||
/*) ;;
|
||||
*) echo "$f: Exec=$exec_path is not absolute" >&2; exit 1 ;;
|
||||
esac
|
||||
test -x "stage${exec_path}" || {
|
||||
echo "$f: Exec=$exec_path is not an executable this package installs" >&2
|
||||
exit 1
|
||||
}
|
||||
echo " Exec -> stage${exec_path} ok"
|
||||
done
|
||||
|
||||
# ---- Fedora -------------------------------------------------------
|
||||
#
|
||||
# The file list is generated rather than written into the spec. Across 27
|
||||
# components a hand-maintained %files would be stale within a week, and
|
||||
# stale in the direction that omits files nobody misses until a login
|
||||
# fails.
|
||||
#
|
||||
# Directories need care. A %dir line for every staged directory would
|
||||
# have the package claim /usr, /usr/bin and /usr/share, which the
|
||||
# `filesystem` package owns -- the RPM would build fine and then refuse to
|
||||
# install, or worse, take those directories with it on uninstall. So a
|
||||
# directory is only claimed if no package on the build system already owns
|
||||
# it, which leaves exactly the ones this fork creates
|
||||
# (/usr/share/hyprcosmic and friends).
|
||||
- name: Build the RPM
|
||||
if: matrix.distro == 'fedora'
|
||||
run: |
|
||||
set -eux
|
||||
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
|
||||
: > files.list
|
||||
find stage -mindepth 1 -type d -printf '%P\n' | while read -r d; do
|
||||
rpm -qf --quiet "/$d" || printf '%%%%dir "/%s"\n' "$d" >> files.list
|
||||
done
|
||||
find stage -mindepth 1 \! -type d -printf '"/%P"\n' >> files.list
|
||||
wc -l files.list
|
||||
rpmbuild -bb packaging/fedora/hyprcosmic.spec \
|
||||
--define "_topdir $PWD/rpmbuild" \
|
||||
--define "stagedir $PWD/stage" \
|
||||
--define "filelist $PWD/files.list" \
|
||||
--define "ver ${{ steps.ver.outputs.version }}"
|
||||
mkdir -p dist
|
||||
find rpmbuild/RPMS -name '*.rpm' -exec cp -v {} dist/ \;
|
||||
|
||||
# A package that installs is the claim being made, so it is tested rather
|
||||
# than assumed. --setopt=tsflags=test does the whole resolution and
|
||||
# conflict check without writing to the container.
|
||||
- name: Verify the RPM
|
||||
if: matrix.distro == 'fedora'
|
||||
run: |
|
||||
set -eux
|
||||
rpm -qpi dist/*.rpm
|
||||
rpm -qp --requires dist/*.rpm
|
||||
dnf -y install --setopt=tsflags=test dist/*.rpm
|
||||
|
||||
# ---- Arch ---------------------------------------------------------
|
||||
#
|
||||
# makepkg refuses to run as root, and a container is root by default, so
|
||||
# the build runs as an unprivileged user that owns the tree it reads.
|
||||
# --nodeps because the depends array names a running system's runtime
|
||||
# libraries, which this image has no reason to hold, and nothing is being
|
||||
# compiled at this point anyway.
|
||||
- name: Build the Arch package
|
||||
if: matrix.distro == 'arch'
|
||||
run: |
|
||||
set -eux
|
||||
useradd -m builder
|
||||
mkdir -p dist
|
||||
cp packaging/arch/PKGBUILD .
|
||||
chown -R builder:builder .
|
||||
sudo -u builder \
|
||||
HYPRCOSMIC_STAGEDIR="$PWD/stage" \
|
||||
HYPRCOSMIC_VERSION="${{ steps.ver.outputs.version }}" \
|
||||
PKGDEST="$PWD/dist" \
|
||||
makepkg --nodeps --noconfirm
|
||||
|
||||
- name: Verify the Arch package
|
||||
if: matrix.distro == 'arch'
|
||||
run: |
|
||||
set -eux
|
||||
pacman -Qip dist/*.pkg.tar.zst
|
||||
# Contents rather than an install: pacman has no dry run that resolves
|
||||
# dependencies without touching the filesystem, and this image is not
|
||||
# a desktop, so a real install would fail on runtime libraries that
|
||||
# say nothing about whether the package is well formed.
|
||||
# To a file and then head: `| head` would kill pacman with SIGPIPE
|
||||
# once head has its 20 lines, and these steps run with pipefail.
|
||||
pacman -Qlp dist/*.pkg.tar.zst > contents.txt
|
||||
echo "entries: $(wc -l < contents.txt)"
|
||||
head -20 contents.txt
|
||||
|
||||
# ---- Debian -------------------------------------------------------
|
||||
#
|
||||
# dpkg-deb --build over a staged tree, rather than a full source package.
|
||||
# The Depends line is computed by dpkg-shlibdeps from the binaries
|
||||
# themselves rather than written by hand -- 27 components link against
|
||||
# more libraries than anyone will keep an accurate list of, and a hand
|
||||
# list is wrong in the direction that installs and then fails to start.
|
||||
#
|
||||
# dpkg-shlibdeps insists on a debian/control in the working directory even
|
||||
# when invoked outside a source package, hence the stub.
|
||||
- name: Build the Debian package
|
||||
if: matrix.distro == 'debian'
|
||||
run: |
|
||||
set -eux
|
||||
mkdir -p debian
|
||||
printf 'Source: hyprcosmic\n\nPackage: hyprcosmic\nArchitecture: amd64\n' > debian/control
|
||||
binaries=$(find stage -type f -perm -100 -exec sh -c 'file -b "$1" | grep -q ELF && echo "$1"' _ {} \;)
|
||||
dpkg-shlibdeps -O --ignore-missing-info $binaries > shlibdeps.txt
|
||||
deps=$(sed 's/^shlibs:Depends=//' shlibdeps.txt)
|
||||
size=$(du -sk stage | cut -f1)
|
||||
|
||||
mkdir -p stage/DEBIAN
|
||||
sed -e "s|@VERSION@|${{ steps.ver.outputs.version }}|" \
|
||||
-e "s|@INSTALLED_SIZE@|$size|" \
|
||||
-e "s|@SHLIB_DEPENDS@|$deps|" \
|
||||
packaging/debian/control.in > stage/DEBIAN/control
|
||||
cat stage/DEBIAN/control
|
||||
|
||||
mkdir -p dist
|
||||
dpkg-deb --build --root-owner-group stage \
|
||||
"dist/hyprcosmic_${{ steps.ver.outputs.version }}_amd64.deb"
|
||||
|
||||
- name: Verify the Debian package
|
||||
if: matrix.distro == 'debian'
|
||||
run: |
|
||||
set -eux
|
||||
dpkg-deb --info dist/*.deb
|
||||
# To a file and then head, not `| head`. Actions runs these steps with
|
||||
# pipefail, and head closing the pipe after 20 lines kills dpkg-deb
|
||||
# with SIGPIPE, which pipefail reports as a failed step -- a green
|
||||
# 217 MB package failed here on nothing but that.
|
||||
dpkg-deb --contents dist/*.deb > contents.txt
|
||||
echo "entries: $(wc -l < contents.txt)"
|
||||
head -20 contents.txt
|
||||
# lintian is not installed and would fail this package on a dozen
|
||||
# policy points that do not apply to a desktop fork shipped outside
|
||||
# the archive. What matters here is that dpkg can read it back.
|
||||
dpkg-deb --fsys-tarfile dist/*.deb | tar -tf - >/dev/null
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: hyprcosmic-${{ matrix.distro }}
|
||||
path: dist/
|
||||
retention-days: 30
|
||||
|
||||
# Only on a tag. A dispatch run is for getting artifacts to try, and turning
|
||||
# one into a public release would make every experiment look like a shipped
|
||||
# version.
|
||||
release:
|
||||
needs: package
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Attach the packages to the release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
# Draft, deliberately. These install beside the distribution's COSMIC
|
||||
# rather than over it, so a bad one costs a logout rather than a
|
||||
# desktop -- but a tag push is still not a decision to publish. The
|
||||
# generated notes are written by a machine reading commit subjects,
|
||||
# and the Debian package differs from the other two in what it
|
||||
# carries; both are worth a human reading before anyone downloads.
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
+12
@@ -1,3 +1,15 @@
|
||||
# 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.
|
||||
|
||||
+18
-2
@@ -1,10 +1,10 @@
|
||||
[submodule "cosmic-session"]
|
||||
path = cosmic-session
|
||||
url = https://github.com/pop-os/cosmic-session
|
||||
url = https://github.com/outbackdingo/hyprcosmic-session
|
||||
branch = master
|
||||
[submodule "cosmic-comp"]
|
||||
path = cosmic-comp
|
||||
url = https://github.com/pop-os/cosmic-comp
|
||||
url = https://github.com/outbackdingo/hyprcosmic-comp
|
||||
branch = master
|
||||
[submodule "cosmic-panel"]
|
||||
path = cosmic-panel
|
||||
@@ -98,3 +98,19 @@
|
||||
path = cosmic-player
|
||||
url = https://github.com/pop-os/cosmic-player.git
|
||||
branch = master
|
||||
[submodule "cosmic-initial-setup"]
|
||||
path = cosmic-initial-setup
|
||||
url = https://github.com/pop-os/cosmic-initial-setup
|
||||
branch = master
|
||||
[submodule "pop-launcher"]
|
||||
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
|
||||
|
||||
@@ -1,211 +1,365 @@
|
||||
# COSMIC Desktop
|
||||
# HyprCosmic
|
||||
|
||||
Currently an incomplete **alpha**. Testing instructions below for various distributions.
|
||||
COSMIC's compositor, driven the way Hyprland is configured, wearing a HyDE
|
||||
shell.
|
||||
|
||||
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-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 installs next to COSMIC rather than over it.** The binaries are
|
||||
`/usr/bin/hyprcosmic-comp`, `/usr/bin/hyprcosmic-session` and
|
||||
`/usr/bin/hyprcosmic-conf`, and nothing here writes a path the distribution
|
||||
owns. The stock COSMIC entry stays on the greeter's menu, served by the
|
||||
distribution's own binaries, so the day the HyDE session does not start is one
|
||||
logout away from a desktop that does. (On Debian, where COSMIC is not
|
||||
packaged, the `.deb` carries the desktop itself — see [Installing](#installing).)
|
||||
|
||||
* [cosmic-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)
|
||||
* [libcosmic](https://github.com/pop-os/libcosmic)
|
||||
## Repository layout
|
||||
|
||||
## Setup on distributions without packaging of cosmic components
|
||||
Everything in `cosmic-epoch`, plus:
|
||||
|
||||
The COSMIC desktop environment requires a few dependencies:
|
||||
(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):
|
||||
| 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 |
|
||||
|
||||
- [just](https://github.com/casey/just)
|
||||
- rustc
|
||||
- libwayland
|
||||
- mesa (or third-party libEGL/libGL implementations, though interfacing with mesa's libglvnd is generally recommended).
|
||||
- libseat
|
||||
- libxkbcommon
|
||||
- libinput
|
||||
- udev
|
||||
- dbus
|
||||
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.
|
||||
|
||||
optionally (though the build-system might currently require these libraries):
|
||||
- libsystem
|
||||
- libpulse
|
||||
- pop-launcher
|
||||
- libexpat1
|
||||
- libfontconfig
|
||||
- libfreetype
|
||||
- lld
|
||||
- cargo
|
||||
- libgbm-dev
|
||||
- libclang-dev
|
||||
- libpipewire-0.3-dev
|
||||
### What the two forks change
|
||||
|
||||
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.
|
||||
**cosmic-comp** — four patches, each independent:
|
||||
|
||||
The required ones can be installed with:
|
||||
```
|
||||
sudo apt install just rustc libglvnd-dev libwayland-dev libseat-dev libxkbcommon-dev libinput-dev udev dbus libdbus-1-dev libpam0g-dev libpixman-1-dev libssl-dev libflatpak-dev -y
|
||||
```
|
||||
- `zwlr_foreign_toplevel_management_v1`, which is the protocol waybar's window
|
||||
list and rofi's window mode read. Without it the taskbar is empty.
|
||||
- A Hyprland-compatible IPC socket (`.socket.sock` and the `.socket2.sock` event
|
||||
stream) under the names Hyprland clients actually open, so HyDE's scripts and
|
||||
waybar's `hyprland/*` modules work unmodified. The write surface is
|
||||
deliberately small: `dispatch exec` and `dispatch killactive` are rejected,
|
||||
because this is the surface any process that can open the socket gets.
|
||||
- New windows open *beside* the focused window rather than inside it.
|
||||
- The install goes to `/usr/bin/hyprcosmic-comp`, alongside upstream's two
|
||||
`.ron` defaults files, which are carried unmodified. The distribution's
|
||||
`cosmic-comp` is left where it is, for the stock session to keep using.
|
||||
|
||||
and the optional ones with:
|
||||
```
|
||||
sudo apt install libsystemd-dev libpulse-dev pop-launcher libexpat1-dev libfontconfig-dev libfreetype-dev mold cargo libgbm-dev libclang-dev libpipewire-0.3-dev -y
|
||||
```
|
||||
**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.
|
||||
|
||||
They can be installed all at once with:
|
||||
```
|
||||
sudo apt install just rustc libglvnd-dev libwayland-dev libseat-dev libxkbcommon-dev libinput-dev udev dbus libdbus-1-dev libsystemd-dev libpixman-1-dev libssl-dev libflatpak-dev libpulse-dev pop-launcher libexpat1-dev libfontconfig-dev libfreetype-dev mold cargo libgbm-dev libclang-dev libpipewire-0.3-dev libpam0g-dev -y
|
||||
```
|
||||
|
||||
### 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`, that you can move (without renaming!) into e.g. `/var/lib/extensions`.
|
||||
After starting systemd-sysext.service (`sudo systemctl enable --now systemd-sysext`) and refreshing (`sudo systemd-sysext refresh`) or rebooting,
|
||||
*COSMIC* will be an available option in your favorite display manager.
|
||||
|
||||
If you have SELinux enabled (e.g. on Fedora), the installed extension won't have the correct labels applied.
|
||||
To test COSMIC, you can temporarily disable it and restart `gdm` (note that this will close your running programs).
|
||||
## Building
|
||||
|
||||
```shell
|
||||
sudo setenforce 0
|
||||
sudo systemctl restart gdm
|
||||
git clone --recurse-submodules https://github.com/outbackdingo/hyprcosmic
|
||||
cd hyprcosmic
|
||||
just build
|
||||
```
|
||||
|
||||
**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).
|
||||
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.
|
||||
|
||||
**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`)
|
||||
## Installing
|
||||
|
||||
It is thus no proper method for long term deployment.
|
||||
|
||||
### Packaging
|
||||
|
||||
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.
|
||||
|
||||
### Versioning
|
||||
|
||||
COSMIC DE is very much still work-in-progress and thus does not follow a versioning scheme so far.
|
||||
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).
|
||||
|
||||
Notes on versioning and packaging all these components together properly will be added at a later stage once COSMIC DE gets its first release.
|
||||
|
||||
## Installing on Pop!_OS
|
||||
COSMIC DE is in its first alpha release. Using and testing the alpha is welcome. Bugs and breakage are expected.
|
||||
|
||||
#### Enable Wayland
|
||||
`sudo nano /etc/gdm3/custom.conf`
|
||||
|
||||
Change `WaylandEnable` to `true`:
|
||||
```
|
||||
WaylandEnable=true
|
||||
```
|
||||
|
||||
Reboot for this change to take effect.
|
||||
|
||||
#### Update udev rules for NVIDIA users
|
||||
The easiest route is a package. Every tag builds one for Fedora, Arch and Debian
|
||||
and attaches it to a draft release; `workflow_dispatch` on **Packages** builds
|
||||
them at any other time and leaves them as run artifacts.
|
||||
|
||||
```shell
|
||||
sudo nano /usr/lib/udev/rules.d/61-gdm.rules
|
||||
sudo dnf install ./hyprcosmic-*.rpm # Fedora
|
||||
sudo pacman -U ./hyprcosmic-*.pkg.tar.zst # Arch
|
||||
sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian
|
||||
```
|
||||
|
||||
Look for `LABEL="gdm_prefer_xorg"` and `LABEL="gdm_disable_wayland"`. Add `#` to the `RUN` statements so they look like this:
|
||||
Nothing is removed and nothing conflicts. COSMIC is a dependency rather than a
|
||||
casualty: the package installs `hyprcosmic-comp`, `hyprcosmic-session` and
|
||||
`hyprcosmic-conf` beside the distribution's, and takes cosmic-settings, the
|
||||
portal, the OSD and the rest from the distribution at the version it tested
|
||||
them at. Log out and pick **HyprCosmic** from the greeter; pick **COSMIC** to go
|
||||
back.
|
||||
|
||||
```
|
||||
LABEL="gdm_prefer_xorg"
|
||||
#RUN+="/usr/libexec/gdm-runtime-config set daemon PreferredDisplayServer xorg"
|
||||
GOTO="gdm_end"
|
||||
An earlier revision did take the `cosmic-*` names, and it could not be
|
||||
installed. Its files collided with 25 distribution packages, and the only way to
|
||||
satisfy that was to erase them — including cosmic-greeter, which on a Fedora
|
||||
COSMIC install *is* the display manager. A desktop you can only try by removing
|
||||
the desktop you would fall back to is not one worth shipping.
|
||||
|
||||
LABEL="gdm_disable_wayland"
|
||||
#RUN+="/usr/libexec/gdm-runtime-config set daemon WaylandEnable false"
|
||||
GOTO="gdm_end"
|
||||
```
|
||||
**Debian is the exception**, because COSMIC is not packaged there — no
|
||||
`cosmic-session`, no `cosmic-comp`, in any suite. There is nothing to depend on
|
||||
and nothing to install beside, so the `.deb` carries the whole desktop it
|
||||
compiled and stands alone, and the greeter offers **HyprCosmic** only. The
|
||||
Fedora and Arch packages ship this fork's three binaries and nothing else.
|
||||
|
||||
Restart gdm
|
||||
Building it yourself instead:
|
||||
|
||||
```shell
|
||||
sudo systemctl restart gdm
|
||||
sudo just install '' /usr
|
||||
```
|
||||
|
||||
#### Install COSMIC
|
||||
`sudo apt install cosmic-session`
|
||||
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.
|
||||
|
||||
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.
|
||||
To stage instead of install:
|
||||
|
||||
## Installing on Arch Linux
|
||||
Install via [cosmic-session](https://archlinux.org/packages/extra/x86_64/cosmic-session/) or the [cosmic](https://archlinux.org/groups/x86_64/cosmic/) group, e.g.:
|
||||
`pacman -S cosmic-session` or `pacman -S cosmic`
|
||||
|
||||
Then log out, click on your user, and a sprocket at the bottom right shows an additional entry alongside your desktop environments. Change to COSMIC and proceed with log in.
|
||||
For a more detailed discussion, consider the [relevant section in the Arch wiki](https://wiki.archlinux.org/title/COSMIC).
|
||||
|
||||
## Installing on Fedora Linux
|
||||
Cosmic may be installed via a Fedora COPR repository.
|
||||
```
|
||||
dnf copr enable ryanabx/cosmic-epoch
|
||||
dnf install cosmic-desktop
|
||||
```shell
|
||||
just install /tmp/stage /usr
|
||||
```
|
||||
|
||||
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 [COPR page](https://copr.fedorainfracloud.org/coprs/ryanabx/cosmic-epoch/).
|
||||
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`.
|
||||
|
||||
Note that `just install` is not what the packages do. It writes upstream's whole
|
||||
desktop at upstream's names, so run against `/usr` on a machine that has COSMIC
|
||||
packaged it will overwrite files your package manager owns. The packages are
|
||||
built from this same tree and then reduced to this fork's own files and renamed;
|
||||
that step lives in `.github/workflows/packages.yml`, not in the justfile, which
|
||||
is upstream's. Stage to a directory and inspect it, or install a package.
|
||||
|
||||
`install` depends on `build`, which is upstream's arrangement and means `sudo
|
||||
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.
|
||||
|
||||
Then log out. `HyprCosmic` appears on the greeter's session menu next to
|
||||
`COSMIC`; both work.
|
||||
|
||||
### Per-user setup
|
||||
|
||||
Nothing to do. The session seeds `~/.config` from `/usr/share/hyprcosmic/skel`
|
||||
at login, copying only what is missing:
|
||||
|
||||
## Installing on openSUSE tumbleweed
|
||||
Cosmic can be installed by adding X11:COSMIC:Factory repo with opi.
|
||||
```
|
||||
opi patterns-cosmic
|
||||
~/.config/hyprcosmic/autostart
|
||||
~/.config/hyprcosmic/cosmic.conf
|
||||
~/.config/hyprcosmic/waybar/style.css
|
||||
~/.config/hyprcosmic/waybar/theme.css
|
||||
~/.config/rofi/config.rasi
|
||||
~/.config/rofi/theme.rasi
|
||||
~/.config/rofi/local.rasi
|
||||
```
|
||||
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).
|
||||
An existing file is never touched, not even when the skeleton is newer — the
|
||||
file wins, one way, and a login is not an invitation to edit your config. Two
|
||||
things follow: your edits survive every login and upgrade, and deleting a file
|
||||
is how you ask for the default back. `~/.cache/hyprcosmic/session.log` records
|
||||
what was seeded.
|
||||
|
||||
## 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:
|
||||
The cost of that is real and worth stating: an upgrade that improves a shipped
|
||||
default will not reach a file you already have. If a release changes something
|
||||
you want — the `autostart` line that sets the wallpaper did change once — take
|
||||
it deliberately, after reading what you would lose:
|
||||
|
||||
`eselect repository add cosmic-overlay git https://github.com/fsvm88/cosmic-overlay.git`
|
||||
```shell
|
||||
diff -u ~/.config/hyprcosmic/autostart /usr/share/hyprcosmic/skel/hyprcosmic/autostart
|
||||
cp /usr/share/hyprcosmic/skel/hyprcosmic/autostart ~/.config/hyprcosmic/
|
||||
```
|
||||
|
||||
Next, install the COSMIC desktop environment and its associated themes:
|
||||
`autostart` is the one that matters most, because it is what starts waybar, the
|
||||
wallpaper daemon and `hyprcosmic-conf watch`. Until the session seeded it, a
|
||||
machine that had never run HyprCosmic logged in to a bare compositor: running,
|
||||
holding the display and accepting input, with nothing drawn on the screen and no
|
||||
binding that opened anything.
|
||||
|
||||
`emerge cosmic-meta pop-theme-meta`
|
||||
`style.css` and `config.rasi` are per-user rather than shared for one reason:
|
||||
each `@import`s a sibling holding the installed HyDE theme's palette, and a
|
||||
relative `@import` resolves against the importing file. Those siblings —
|
||||
`theme.css`, `theme.rasi` and `local.rasi` — arrive empty and are written by
|
||||
`import-theme --assets`. They ship empty rather than not at all because a
|
||||
missing `@import` is fatal to both consumers rather than a warning they skip:
|
||||
GTK fails the entire stylesheet, and rofi reports the error in place of the
|
||||
launcher.
|
||||
|
||||
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 [Gentoo Wiki](https://wiki.gentoo.org/wiki/COSMIC) or [Overlay Repository](https://github.com/fsvm88/cosmic-overlay).
|
||||
Working from a git checkout, `just install` still places nothing in a home
|
||||
directory — under `sudo` the only home directory it could see is root's — but it
|
||||
does install the skeleton, so logging in seeds the same seven files.
|
||||
|
||||
## Contact
|
||||
- [Mattermost](https://chat.pop-os.org/)
|
||||
- [Twitter](https://twitter.com/pop_os_official)
|
||||
- [Instagram](https://www.instagram.com/pop_os_official/)
|
||||
Runtime dependencies of the shell itself are not COSMIC's and are not built
|
||||
here: `waybar`, `rofi` (wayland build), `awww` (formerly `swww`), and a Nerd
|
||||
Font for the glyphs the bar and the launcher draw with.
|
||||
|
||||
The wallpaper is `awww`'s job, and the image it draws is
|
||||
`~/.local/share/wallpapers/hyprcosmic/current` — a symlink `import-theme
|
||||
--assets` maintains. Before you have imported a theme there is no such link, so
|
||||
`hyprcosmic-wallpaper` falls back to whatever the distribution ships,
|
||||
`/usr/share/backgrounds/cosmic` first; the Fedora package recommends
|
||||
`cosmic-wallpapers` so there is something there. With no `awww` installed it
|
||||
says so on the session log and leaves the background alone, rather than waiting
|
||||
for a daemon that is never coming.
|
||||
|
||||
The font is the one thing the packaging cannot do for you on Fedora, which has
|
||||
no package that provides a Nerd Font at all: `texlive-inconsolata-nerd-font`
|
||||
installs under `texmf-dist` and kitty's `SymbolsNerdFont` under
|
||||
`/usr/lib64/kitty`, and fontconfig scans neither. Arch has
|
||||
`ttf-nerd-fonts-symbols`. Otherwise, install one into your own font directory:
|
||||
|
||||
```shell
|
||||
mkdir -p ~/.local/share/fonts/JetBrainsMonoNerdFont
|
||||
# unpack JetBrainsMono.zip from github.com/ryanoasis/nerd-fonts/releases there
|
||||
fc-cache -f
|
||||
fc-list ":charset=e0b0" family | grep -i nerd # non-empty: the glyphs resolve
|
||||
```
|
||||
|
||||
Query the charset on its own, as above. Adding a family filter —
|
||||
`":charset=e0b0:family=JetBrainsMono Nerd Font"` — reports nothing even when the
|
||||
font does cover the codepoint, because the family string is a comma-separated
|
||||
alias list (`JetBrainsMono Nerd Font,JetBrainsMono NF`). Without the font the
|
||||
bar still works; every icon is a tofu box.
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.config/hyprcosmic/cosmic.conf`, in Hyprland's idiom, compiled into
|
||||
`cosmic-config` by:
|
||||
|
||||
```shell
|
||||
hyprcosmic-conf apply # once
|
||||
hyprcosmic-conf apply --diff # show what would change, write nothing
|
||||
hyprcosmic-conf watch # recompile on every edit, for the whole session
|
||||
```
|
||||
|
||||
`watch` is the first line of the shipped `autostart`, which is what makes "the
|
||||
file wins" true at login and not only when you last ran `apply` by hand:
|
||||
whatever COSMIC's settings UI stored since then is overwritten before the
|
||||
desktop settles. A malformed edit is reported to the session log and the last
|
||||
good configuration stays in place, so a typo cannot leave you at a broken
|
||||
desktop.
|
||||
|
||||
The rule is one-way and deliberate. Keys this file names are overwritten from
|
||||
it on every login; keys it does not name are left entirely alone, so
|
||||
cosmic-settings remains the right place to change anything the file is silent
|
||||
about. There is no write-back — the GUI never edits `cosmic.conf`.
|
||||
|
||||
`bind` lines go to the Shortcuts `custom` key, which cosmic-comp merges over
|
||||
`defaults`, so the system defaults file is never touched and reverting is a
|
||||
matter of deleting the lines and re-applying. Hyprland spellings and COSMIC
|
||||
spellings are both accepted for the same setting (`input:follow_mouse` and
|
||||
`general:focus_follows_cursor`), and the last assignment wins. Where a Hyprland
|
||||
value has no COSMIC equivalent — `follow_mouse = 2` and `3`, which separate
|
||||
pointer focus from keyboard focus — it is rejected with an explanation rather
|
||||
than quietly rounded.
|
||||
|
||||
What the shipped file sets up, since the components those keys used to reach are
|
||||
no longer running:
|
||||
|
||||
| Binding | Does |
|
||||
| --- | --- |
|
||||
| `Super` (tap), `Super+/`, `Super+A` | `rofi -show drun` |
|
||||
| `Super+W` | `rofi -show window`, in place of the workspace overview |
|
||||
| `Super+Return` | `cosmic-term` (`Super+T` still works — cosmic-comp handles that one itself) |
|
||||
| `Super+Shift+E` | `hyprcosmic-powermenu`: lock, suspend, log out, reboot, shut down |
|
||||
|
||||
The power menu is there because cosmic-panel hosts COSMIC's power applet, and
|
||||
without the panel a session had no way out short of `systemctl reboot` from a
|
||||
terminal. The same script backs waybar's power button, so the two cannot drift
|
||||
apart, and it confirms before anything that ends the session.
|
||||
|
||||
See [`config/cosmic.conf`](config/cosmic.conf); it is commented at length and is
|
||||
the reference for what is supported.
|
||||
|
||||
## Theming
|
||||
|
||||
```shell
|
||||
hyprcosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \
|
||||
--out ~/.config/hyprcosmic/theme.conf --report --assets
|
||||
```
|
||||
|
||||
This translates a HyDE theme into conf keys, and with `--assets` also installs
|
||||
the wallpapers, GTK and icon themes, and the waybar/rofi/kitty theme files that
|
||||
sit beside `hypr.theme`. `--report` prints everything that did not translate
|
||||
cleanly, which is the honest half of the output.
|
||||
|
||||
`theme.conf` is written as a separate file and `source`d from `cosmic.conf`
|
||||
rather than pasted into it. That keeps re-importing from touching your
|
||||
keybindings, and anything you want to override can simply be repeated later in
|
||||
`cosmic.conf`, since the last assignment to a key wins. The `source` line ships
|
||||
commented out — a `source` naming a file that does not exist is a hard error,
|
||||
and no theme is imported on a fresh install. Uncomment it once you have run the
|
||||
command above; `import-theme` says so as well.
|
||||
|
||||
Change the wallpaper by repointing the `current` symlink that `--assets`
|
||||
maintains, not by editing `autostart`:
|
||||
|
||||
```shell
|
||||
ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
|
||||
~/.local/share/wallpapers/hyprcosmic/current
|
||||
```
|
||||
|
||||
## Continuous integration
|
||||
|
||||
Two workflows, on purpose:
|
||||
|
||||
- `.github/workflows/ci.yml` is upstream's, unmodified. It builds the entire
|
||||
desktop on Arch via `just sysext`, which is exactly the check a meta-repo
|
||||
wants and is not made less useful by forking.
|
||||
- `.github/workflows/hyprcosmic.yml` covers what upstream's does not:
|
||||
`cosmic-conf` built, tested and clippy-clean on Fedora, Debian and Arch; a
|
||||
check that the shipped `cosmic.conf` still parses and resolves against the
|
||||
current schema; that `config/waybar/config.jsonc` is still in step with the
|
||||
generator that produces it; that the template and generator stay pure ASCII;
|
||||
and an `install-assets.sh` round trip into a staging root, verified with
|
||||
`--check`.
|
||||
|
||||
The two forks carry a `hyprcosmic.yml` of the same shape, each building on
|
||||
Fedora, Debian and Arch and asserting that its install landed at upstream's
|
||||
paths — and that nothing landed in the private `/usr/libexec/hyprcosmic/` this
|
||||
fork used to use, which is the assertion that would otherwise rot quietly.
|
||||
|
||||
`packages.yml` in this repository builds installable packages for the same three
|
||||
distributions: an RPM, a `.pkg.tar.zst` and a `.deb`, each compiled inside a
|
||||
container of the distribution it targets so the sonames it records are the ones
|
||||
the installing machine will have. It runs on tags and on demand, not on every
|
||||
push — three full desktop builds is hours of runner time. Tags additionally open
|
||||
a **draft** release with the packages attached; drafts rather than published,
|
||||
because installing one of these replaces the machine's desktop.
|
||||
|
||||
The waybar generator deserves its own note. `config.jsonc` is generated from
|
||||
`config.jsonc.in` and a codepoint table in `generate-config.py`, and is never
|
||||
hand-edited: Nerd Font glyphs live in the Private Use Area, where they are
|
||||
destroyed by being retyped and indistinguishable from each other in a diff. CI
|
||||
regenerates the file and fails if it moves.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- The `/usr/share/hyprcosmic` literals described under [Installing](#installing).
|
||||
- `docs/unreproducible-dead-input-2026-08-10.md` records a session that came up
|
||||
without input and has not been reproduced since. It is written down rather
|
||||
than closed.
|
||||
|
||||
## Trademark
|
||||
|
||||
COSMIC is a System76 trademark. This fork is not affiliated with or endorsed by
|
||||
System76. See [TRADEMARK.md](TRADEMARK.md), which is upstream's policy and
|
||||
applies here.
|
||||
|
||||
## Upstream
|
||||
|
||||
For COSMIC itself — the component list, packaging status, translations, and how
|
||||
to install it on your distribution rather than building it — see
|
||||
[pop-os/cosmic-epoch](https://github.com/pop-os/cosmic-epoch).
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Programs the HyprCosmic session starts after COSMIC's own components.
|
||||
#
|
||||
# Installed to ~/.config/hyprcosmic/autostart and read by cosmic-session's
|
||||
# profile module. One command per line. Arguments and quoting work, but this is
|
||||
# NOT a shell: no $VAR, no ~, no globs, no $(...). Paths must be absolute.
|
||||
#
|
||||
# `#` starts a comment where a word would start, so `--color=#1a1b26` is fine
|
||||
# but `--color #1a1b26` is not; quote it as '#1a1b26' if you need the latter.
|
||||
|
||||
# Keep cosmic-config in step with cosmic.conf for the whole session: compile
|
||||
# once at login, then again on every edit to it or to anything it sources.
|
||||
#
|
||||
# First in the file because its startup pass is what makes "the file wins" true
|
||||
# at login rather than only when you last ran `apply` by hand -- whatever
|
||||
# COSMIC's settings UI stored since then is overwritten before the desktop
|
||||
# settles. The bar does not read cosmic-config, so the ordering is for the
|
||||
# compositor's benefit, not waybar's.
|
||||
#
|
||||
# No --config: the default is derived from XDG_CONFIG_HOME (or HOME) inside the
|
||||
# process, so unlike the waybar line below this needs no shell to find a home
|
||||
# directory for it.
|
||||
#
|
||||
# A malformed edit is not fatal. It is reported to the session log and the last
|
||||
# good configuration stays in place, so a typo cannot leave you at a broken
|
||||
# desktop -- fix the file and the next save applies.
|
||||
hyprcosmic-conf watch
|
||||
|
||||
# The bar. The layout is shared and lives under /usr/share, but the stylesheet
|
||||
# has to be per-user: it imports a sibling theme.css holding the installed HyDE
|
||||
# theme's palette, and a relative @import resolves against the importing file.
|
||||
#
|
||||
# So the style path has to name a home directory, and this file is not a shell:
|
||||
# `~` and `$HOME` on a bare command line here are literal text, not expansions.
|
||||
# `sh -c` is what gets them expanded, for the same reason and under the same
|
||||
# terms as the wallpaper line below -- naming `sh` is naming a program, which
|
||||
# this file was always allowed to do.
|
||||
#
|
||||
# `exec` matters. Without it sh stays in the process tree as waybar's parent,
|
||||
# and cosmic-session would be supervising the shell rather than the bar: a
|
||||
# waybar that died would leave sh alive, so the restart that should have
|
||||
# happened never would.
|
||||
sh -c 'exec waybar -c /usr/share/hyprcosmic/waybar/config.jsonc -s "$HOME/.config/hyprcosmic/waybar/style.css"'
|
||||
|
||||
# rofi is not a daemon. It is launched on demand by a keybinding, which COSMIC
|
||||
# stores in com.system76.CosmicSettings.Shortcuts rather than here. Set those
|
||||
# with `bind` lines in cosmic.conf and `cosmic-conf apply`; see config/cosmic.conf.
|
||||
|
||||
# Wallpaper. The hyprcosmic profile does not start cosmic-bg, so without this
|
||||
# there is nothing drawing a background at all.
|
||||
#
|
||||
# HyDE calls this swww; upstream renamed it to awww at 0.12 and the Fedora
|
||||
# package Obsoletes swww < 0.12.0. /usr/bin/swww is a shim that prints a
|
||||
# deprecation warning and is documented as going away, so use the real name.
|
||||
# Packaged in the alebastr/sway-extras COPR.
|
||||
#
|
||||
# The daemon only holds the surface -- it shows nothing until an image is set.
|
||||
awww-daemon
|
||||
|
||||
# ...which is what this does. Without it the daemon runs, draws nothing, and you
|
||||
# get a blank screen below the bar with no error anywhere: the failure is that
|
||||
# nobody asked for a wallpaper, so nothing reports one missing.
|
||||
#
|
||||
# It sets ~/.local/share/wallpapers/hyprcosmic/current, a symlink
|
||||
# `hyprcosmic-conf import-theme --assets` maintains beside the wallpapers it
|
||||
# copies. The link is used rather than a real path so that this and rofi's
|
||||
# local.rasi -- which shows the same image in the launcher's sidebar -- cannot
|
||||
# drift apart, and so that importing a different theme does not leave either
|
||||
# pointing at a file that no longer exists.
|
||||
#
|
||||
# Until a theme has been imported there is no such link, so on a machine that
|
||||
# has just installed the package it falls back to whatever the distribution
|
||||
# ships, /usr/share/backgrounds/cosmic first. A first login gets a desktop.
|
||||
#
|
||||
# Change the wallpaper by repointing the link, not by editing this file:
|
||||
#
|
||||
# ln -sfn ~/".local/share/wallpapers/hyprcosmic/<theme>/<image>" \
|
||||
# ~/.local/share/wallpapers/hyprcosmic/current
|
||||
#
|
||||
# A program rather than the `sh -c` this used to be, because waiting for the
|
||||
# daemon needs a bound: awww is a Recommends in a COPR, and the old unbounded
|
||||
# `until awww query` loop spun for the whole session on a machine without it.
|
||||
hyprcosmic-wallpaper
|
||||
|
||||
# A terminal, unconditionally, as the way back in.
|
||||
#
|
||||
# Everything above assumes the keybindings work. If they do not -- a bad
|
||||
# `bind` line, a shortcut COSMIC declines to register, a compositor that came
|
||||
# up without input -- then there is no way to launch rofi, no way to launch a
|
||||
# terminal, and the only remaining option is a VT switch. Starting one terminal
|
||||
# at login costs a window you can close and removes that entire failure class.
|
||||
#
|
||||
# Last in the file so it is the most recently mapped window, and therefore on
|
||||
# top of anything else that opened during startup.
|
||||
cosmic-term
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/bin/sh
|
||||
# Report fan speeds for waybar's custom/fan module, as JSON on stdout.
|
||||
#
|
||||
# waybar has no fan module, and its temperature module cannot be borrowed for
|
||||
# this: it divides by 1000 to turn millidegrees into degrees, which would render
|
||||
# 2700 rpm as 2 C.
|
||||
#
|
||||
# Nor can the sensor be named the way "temperature" names its own, with
|
||||
# hwmon-path-abs. That option points at a *parent* directory and takes the one
|
||||
# hwmonN inside it, which works for k10temp and amdgpu because each has exactly
|
||||
# one. The asus platform device has two --
|
||||
#
|
||||
# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon9 name=asus
|
||||
# /sys/devices/platform/asus-nb-wmi/hwmon/hwmon10 name=asus_custom_fan_curve
|
||||
#
|
||||
# -- and only the first has fan*_input; the second holds the curve's set points.
|
||||
# Which of the two waybar picked would be down to readdir order.
|
||||
#
|
||||
# So this resolves by content instead of by path: any hwmon with a fan*_input.
|
||||
# That also makes it portable off this laptop, which a hardcoded asus path would
|
||||
# not be. Fans on a desktop's it87 or a thinkpad's thinkpad_hwmon are found the
|
||||
# same way.
|
||||
#
|
||||
# Prints nothing at all when the machine has no readable fan -- a VM, or a
|
||||
# passively cooled box. waybar renders an empty custom module as nothing, so the
|
||||
# bar loses the item rather than showing a dead zero.
|
||||
|
||||
set -eu
|
||||
|
||||
max=0
|
||||
tooltip=''
|
||||
|
||||
for hwmon in /sys/class/hwmon/hwmon*; do
|
||||
[ -d "$hwmon" ] || continue
|
||||
|
||||
# Not every hwmon has a name, and a chip is worth naming in the tooltip
|
||||
# when it does: "cpu_fan" alone does not say which controller reported it.
|
||||
chip=$(cat "$hwmon/name" 2>/dev/null) || chip=''
|
||||
[ -n "$chip" ] || chip=$(basename "$hwmon")
|
||||
|
||||
for input in "$hwmon"/fan*_input; do
|
||||
# The glob is literal when nothing matches, which is the common case:
|
||||
# most hwmons here are temperature-only.
|
||||
[ -e "$input" ] || continue
|
||||
|
||||
rpm=$(cat "$input" 2>/dev/null) || continue
|
||||
# A fan that is stopped reads 0, and a fan that has been unbound reads
|
||||
# nothing. Neither is an error, but neither belongs in the tooltip.
|
||||
case $rpm in
|
||||
'' | *[!0-9]*) continue ;;
|
||||
esac
|
||||
|
||||
# fanN_label when the driver supplies one -- "cpu_fan", "gpu_fan" --
|
||||
# and fanN otherwise.
|
||||
label=$(cat "${input%_input}_label" 2>/dev/null) || label=''
|
||||
[ -n "$label" ] || label=$(basename "${input%_input}")
|
||||
|
||||
[ "$rpm" -gt "$max" ] && max=$rpm
|
||||
|
||||
line="$chip $label: $rpm rpm"
|
||||
if [ -n "$tooltip" ]; then
|
||||
tooltip="$tooltip\\n$line"
|
||||
else
|
||||
tooltip=$line
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# No fan anywhere. Say nothing rather than reporting a confident 0 rpm.
|
||||
[ -n "$tooltip" ] || exit 0
|
||||
|
||||
# The bar shows the fastest fan, because that is the one you can hear and the
|
||||
# one that says whether the machine is working. The rest are in the tooltip.
|
||||
#
|
||||
# The class drives the colour in rules.css. 4000 rpm is where this laptop's fans
|
||||
# become audible over a quiet room; below 1 rpm every fan is stopped, which is
|
||||
# worth showing differently from a slow one.
|
||||
if [ "$max" -ge 4000 ]; then
|
||||
class=high
|
||||
elif [ "$max" -eq 0 ]; then
|
||||
class=idle
|
||||
else
|
||||
class=normal
|
||||
fi
|
||||
|
||||
printf '{"text":"%s","tooltip":"%s","class":"%s"}\n' "$max" "$tooltip" "$class"
|
||||
Executable
+205
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# Show every keyboard shortcut in the session, and what each one runs.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# COSMIC has no keyboard reference. cosmic-settings lists shortcuts in a
|
||||
# scrolling pane behind three clicks, which is where you go to *change* one, not
|
||||
# where you go to remember one mid-task. Hyprland desktops all ship a cheatsheet
|
||||
# on a key and a bar click for exactly that reason, and this is that.
|
||||
#
|
||||
# WHERE THE BINDINGS COME FROM, AND WHY NOT cosmic.conf
|
||||
# -----------------------------------------------------
|
||||
# The obvious implementation reads cosmic.conf, and it would be wrong. This
|
||||
# machine's cosmic.conf declares six bindings. The session answers to 122. The
|
||||
# other 116 are COSMIC's own defaults, which the fork does not restate because
|
||||
# it has no reason to -- Super+Q closes a window whether or not anybody wrote it
|
||||
# down. A cheatsheet showing six entries would not look broken; it would look
|
||||
# complete, and it would be missing every window, workspace and media key on the
|
||||
# machine. That is a worse failure than not having the feature.
|
||||
#
|
||||
# So the sources are the three RON files the compositor actually reads:
|
||||
#
|
||||
# /usr/share/cosmic/.../Shortcuts/v1/defaults 116 COSMIC built-ins
|
||||
# ~/.config/cosmic/.../Shortcuts/v1/custom 6 ours, projected from
|
||||
# cosmic.conf by cosmic-conf
|
||||
# /usr/share/cosmic/.../Shortcuts/v1/system_actions
|
||||
# what System(X) actually runs
|
||||
#
|
||||
# custom wins over defaults on a collision, matching the order the compositor
|
||||
# merges them, so a rebound key is listed once with the binding in force.
|
||||
#
|
||||
# system_actions is what makes this answer the question that was asked. A
|
||||
# default reads `System(Screenshot)`, which names an action and not a program;
|
||||
# system_actions maps that to `cosmic-screenshot`. Without it a third of the
|
||||
# list would name internal actions rather than the applications they launch.
|
||||
#
|
||||
# THE PARSER
|
||||
# ----------
|
||||
# These are RON files, and this reads them with a regex, which deserves a
|
||||
# defence. The alternative is a `cosmic-conf binds` subcommand, which would be
|
||||
# the principled home for this -- cosmic-conf already has the schema and the
|
||||
# bind grammar. That is a compiled change and a full package build, and this
|
||||
# feature does not need one: the files are one binding per line, and the shape
|
||||
# has not moved. The cost of being wrong here is a mangled row in a reference
|
||||
# window, not a broken keybinding, because nothing downstream consumes this.
|
||||
#
|
||||
# If the format does move, the failure is visible immediately and loudly: the
|
||||
# count check below refuses to show a list it could not parse.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SYS_DIR=/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1
|
||||
USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/cosmic/com.system76.CosmicSettings.Shortcuts/v1"
|
||||
|
||||
command -v rofi >/dev/null 2>&1 || {
|
||||
printf 'hyprcosmic-keybinds: rofi is not installed\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Errors go to a rofi dialog as well as stderr: the two ways in are a keybinding
|
||||
# and a bar click, and neither has a terminal to read stderr from. Same reasoning
|
||||
# as hyprcosmic-powermenu.
|
||||
fail() {
|
||||
printf 'hyprcosmic-keybinds: %s\n' "$*" >&2
|
||||
rofi -e "$*" >/dev/null 2>&1 || :
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -r "$SYS_DIR/defaults" ]] ||
|
||||
fail "cannot read $SYS_DIR/defaults, so the built-in shortcuts are unknown"
|
||||
|
||||
# Resolve System(X) to the command it runs, as "X<tab>command" pairs.
|
||||
#
|
||||
# system_actions carries /// doc comments and commented-out entries for actions
|
||||
# that exist in the enum but are not wired up (KeyboardBrightnessUp is one), so
|
||||
# this matches only lines that are a bare name followed by a quoted string.
|
||||
system_actions() {
|
||||
[[ -r "$SYS_DIR/system_actions" ]] || return 0
|
||||
sed -nE 's/^[[:space:]]*([A-Za-z]+):[[:space:]]*"(.*)",?[[:space:]]*$/\1\t\2/p' \
|
||||
"$SYS_DIR/system_actions"
|
||||
}
|
||||
|
||||
# One binding per line, as "modifiers<tab>key<tab>action".
|
||||
#
|
||||
# Two shapes appear. The usual one carries a key:
|
||||
# (modifiers: [Super, Shift], key: "Escape"): System(LogOut),
|
||||
# and a modifier-only binding has none at all:
|
||||
# (modifiers: [Super]): Spawn("rofi -show drun"),
|
||||
# The second is how Super alone opens the launcher, so dropping it would lose
|
||||
# the single most used binding on the machine.
|
||||
bindings() {
|
||||
sed -nE \
|
||||
-e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\],[[:space:]]*key:[[:space:]]*"([^"]*)"\):[[:space:]]*(.*),[[:space:]]*$/\1\t\2\t\3/p' \
|
||||
-e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\]\):[[:space:]]*(.*),[[:space:]]*$/\1\t\t\2/p' \
|
||||
"$1"
|
||||
}
|
||||
|
||||
actions_tsv="$(system_actions)"
|
||||
|
||||
# custom is read second so that its rows overwrite defaults in the map below.
|
||||
# Missing is normal, not an error: a session with no custom bindings has no such
|
||||
# file, and every default still applies.
|
||||
raw="$(bindings "$SYS_DIR/defaults")"
|
||||
if [[ -r "$USER_DIR/custom" ]]; then
|
||||
raw+=$'\n'"$(bindings "$USER_DIR/custom")"
|
||||
fi
|
||||
|
||||
[[ -n "${raw//[[:space:]]/}" ]] ||
|
||||
fail "parsed no shortcuts at all from $SYS_DIR/defaults; the file format has changed"
|
||||
|
||||
rendered="$(
|
||||
LC_ALL=C awk -F'\t' -v actions="$actions_tsv" '
|
||||
BEGIN {
|
||||
# System(X) -> command, from system_actions.
|
||||
n = split(actions, lines, "\n")
|
||||
for (i = 1; i <= n; i++) {
|
||||
if (split(lines[i], kv, "\t") == 2) cmd[kv[1]] = kv[2]
|
||||
}
|
||||
}
|
||||
|
||||
# Turn CamelCase into words so an action reads as a description rather than
|
||||
# an identifier: MoveToWorkspace -> "move to workspace". Applied only to the
|
||||
# action name, never to a command, which must stay verbatim to be typed.
|
||||
function words(s, out) {
|
||||
out = s
|
||||
gsub(/([a-z0-9])([A-Z])/, "\\1 \\2", out)
|
||||
return tolower(out)
|
||||
}
|
||||
|
||||
# Super is written first and Shift last, so that chords sort and read the
|
||||
# way they are pressed rather than the way the RON happened to list them.
|
||||
function order(m) {
|
||||
if (m == "Super") return 1
|
||||
if (m == "Ctrl") return 2
|
||||
if (m == "Alt") return 3
|
||||
if (m == "Shift") return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
{
|
||||
mods = $1; key = $2; action = $3
|
||||
if (action == "") next
|
||||
|
||||
# "Super, Alt" -> "Super + Alt", in a fixed order.
|
||||
cnt = split(mods, m, /,[[:space:]]*/)
|
||||
for (i = 1; i <= cnt; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", m[i])
|
||||
for (i = 1; i < cnt; i++)
|
||||
for (j = i + 1; j <= cnt; j++)
|
||||
if (order(m[j]) < order(m[i])) { t = m[i]; m[i] = m[j]; m[j] = t }
|
||||
|
||||
chord = ""
|
||||
for (i = 1; i <= cnt; i++) chord = chord (chord == "" ? "" : " + ") m[i]
|
||||
if (key != "") chord = chord (chord == "" ? "" : " + ") key
|
||||
|
||||
# Spawn("cmd") is a command as written in cosmic.conf.
|
||||
# System(X) is an action name that system_actions turns into a command.
|
||||
# Everything else is internal to the compositor and has no command; the
|
||||
# action itself is the honest answer, and inventing a program name for
|
||||
# Focus(Left) would be a lie.
|
||||
what = ""
|
||||
if (action ~ /^Spawn\(".*"\)$/) {
|
||||
what = substr(action, 8, length(action) - 9)
|
||||
} else if (action ~ /^System\(/) {
|
||||
name = action
|
||||
sub(/^System\(/, "", name); sub(/\)$/, "", name)
|
||||
what = (name in cmd) ? cmd[name] : words(name)
|
||||
} else if (action ~ /\(/) {
|
||||
name = action; arg = action
|
||||
sub(/\(.*$/, "", name)
|
||||
sub(/^[^(]*\(/, "", arg); sub(/\)$/, "", arg)
|
||||
what = words(name) " " arg
|
||||
} else {
|
||||
what = words(action)
|
||||
}
|
||||
|
||||
# Last write wins, which is why custom is appended after defaults.
|
||||
seen[chord] = what
|
||||
if (!(chord in orderkey)) orderkey[chord] = ++seq
|
||||
}
|
||||
|
||||
END {
|
||||
for (c in seen) printf "%d\t%s\t%s\n", orderkey[c], c, seen[c]
|
||||
}
|
||||
' <<<"$raw" | sort -n | cut -f2-
|
||||
)"
|
||||
|
||||
count="$(grep -c . <<<"$rendered")"
|
||||
((count > 0)) || fail "every shortcut line failed to parse; the RON format has changed"
|
||||
|
||||
# Column-aligned so the chords form a readable left edge. Done here rather than
|
||||
# in awk because the width has to be measured across the whole set first.
|
||||
width="$(cut -f1 <<<"$rendered" | LC_ALL=C awk '{ if (length($0) > w) w = length($0) } END { print w }')"
|
||||
|
||||
# -dmenu rather than a static window, so the list is filterable: 122 bindings is
|
||||
# more than fits on a screen, and typing "workspace" is how you actually use
|
||||
# this. No theme arguments, so ~/.config/rofi/config.rasi applies and it matches
|
||||
# the launcher and the power menu.
|
||||
#
|
||||
# The selection is discarded. This is a reference, not a menu -- there is no
|
||||
# sensible action for "you picked Super + Q", and running the bound command on
|
||||
# Return would make an accidental keypress close a window from a help screen.
|
||||
LC_ALL=C awk -F'\t' -v w="$width" '{ printf "%-*s %s\n", w, $1, $2 }' <<<"$rendered" |
|
||||
rofi -dmenu -i -no-custom -no-show-icons -p "keys" \
|
||||
-mesg "$count shortcuts" >/dev/null 2>&1 || :
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# Power menu for a HyprCosmic session: lock, suspend, log out, reboot, shut down.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# Stock COSMIC puts all of these behind the power applet in cosmic-panel.
|
||||
# HyprCosmic replaces the panel with waybar, so that applet never starts and the
|
||||
# session had no exit at all -- not even a logout -- short of `systemctl reboot`
|
||||
# typed into a terminal. This is the exit. It is reached from a keybinding and
|
||||
# from the bar, and both run this same script so the two can never disagree.
|
||||
#
|
||||
# Logging out goes through cosmic-session's own D-Bus interface rather than
|
||||
# killing anything. That is the method the panel applet called, and it is the
|
||||
# only one that lets the session stop its clients in order instead of pulling
|
||||
# the compositor out from under them; see `com.system76.CosmicSession.Exit` in
|
||||
# cosmic-session/src/service.rs.
|
||||
#
|
||||
# Reboot and poweroff go to systemd directly. polkit already authorises both for
|
||||
# an active local session without a prompt, which is why no pkexec is involved.
|
||||
#
|
||||
# NO GLYPHS HERE, DELIBERATELY
|
||||
# ----------------------------
|
||||
# Every entry is a plain word. Nerd Font icons live in the Private Use Area,
|
||||
# where they are indistinguishable from each other in a diff and are silently
|
||||
# destroyed by anything that retypes rather than copies them. waybar's power
|
||||
# icon is the only glyph in this feature and it comes from generate-config.py,
|
||||
# which checks each codepoint against the installed font.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SESSION_DEST=com.system76.CosmicSession
|
||||
SESSION_PATH=/com/system76/CosmicSession
|
||||
|
||||
command -v rofi >/dev/null 2>&1 || {
|
||||
printf 'hyprcosmic-powermenu: rofi is not installed\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Errors go to a rofi dialog, not just stderr. The two ways in are a keybinding
|
||||
# and a bar click, and neither has a terminal attached to read stderr from.
|
||||
fail() {
|
||||
printf 'hyprcosmic-powermenu: %s\n' "$*" >&2
|
||||
rofi -e "$*" >/dev/null 2>&1 || :
|
||||
exit 1
|
||||
}
|
||||
|
||||
# No theme arguments, so ~/.config/rofi/config.rasi applies and this matches the
|
||||
# launcher. Icons off because these entries have none and the reserved space
|
||||
# would sit there empty.
|
||||
#
|
||||
# -no-custom is what stops a typed line from being returned as if it were a
|
||||
# choice: without it, Return on an empty filter hands back whatever was typed,
|
||||
# and the case below would fall through to no branch at all. With it, anything
|
||||
# that is not one of the offered entries is refused.
|
||||
menu() {
|
||||
local prompt="$1"
|
||||
shift
|
||||
printf '%s\n' "$@" | rofi -dmenu -i -no-custom -no-show-icons -p "$prompt"
|
||||
}
|
||||
|
||||
# Only for the three that end the session. Locking and suspending undo
|
||||
# themselves with a keypress, so a confirmation there is pure friction; logging
|
||||
# out, rebooting and shutting down each throw away every unsaved thing on the
|
||||
# desktop, and this menu is one keystroke away at all times.
|
||||
#
|
||||
# "No" is listed first so that it is the selected row when the dialog opens.
|
||||
confirm() {
|
||||
local answer
|
||||
answer="$(menu "$1?" "No" "Yes")" || return 1
|
||||
[[ "$answer" == "Yes" ]]
|
||||
}
|
||||
|
||||
logout() {
|
||||
command -v busctl >/dev/null 2>&1 ||
|
||||
fail "busctl is not installed, so the session cannot be asked to exit"
|
||||
|
||||
busctl --user call "$SESSION_DEST" "$SESSION_PATH" "$SESSION_DEST" Exit && return 0
|
||||
|
||||
# Reaching here means cosmic-session is not answering on the bus. Say so
|
||||
# rather than silently doing nothing: being unable to leave the session is
|
||||
# the exact problem this script was written for, so a dead end here is
|
||||
# worse than a blunt instrument. loginctl is that blunt instrument, and it
|
||||
# is named explicitly so the choice to use it is the user's.
|
||||
fail "cosmic-session did not answer on D-Bus.
|
||||
To force the session to end, run:
|
||||
loginctl terminate-session ${XDG_SESSION_ID:-\$XDG_SESSION_ID}"
|
||||
}
|
||||
|
||||
choice="$(menu "Power" "Lock" "Suspend" "Log out" "Reboot" "Shut down")" || exit 0
|
||||
|
||||
case "$choice" in
|
||||
"Lock") exec loginctl lock-session ;;
|
||||
"Suspend") exec systemctl suspend ;;
|
||||
"Log out") confirm "Log out" && logout ;;
|
||||
"Reboot") confirm "Reboot" && exec systemctl reboot ;;
|
||||
"Shut down") confirm "Shut down" && exec systemctl poweroff ;;
|
||||
"") ;;
|
||||
*) fail "unrecognised choice: ${choice}" ;;
|
||||
esac
|
||||
|
||||
# Reached only by answering "No" to a confirmation, or dismissing it. The four
|
||||
# branches that act replace this process outright, and `fail` exits on its own,
|
||||
# so nothing else arrives here -- and changing your mind is not a failure.
|
||||
exit 0
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/sh
|
||||
# Set the desktop wallpaper once the wallpaper daemon is listening.
|
||||
#
|
||||
# The hyprcosmic profile does not start cosmic-bg, so nothing else draws a
|
||||
# background: without this there is a black screen below the bar, and no error
|
||||
# anywhere, because the failure is that nobody asked for a wallpaper.
|
||||
#
|
||||
# This was three shell fragments on one autostart line. It became a program for
|
||||
# two reasons, both of which cost a first login its desktop:
|
||||
#
|
||||
# - `current` is a symlink `import-theme --assets` maintains, so it does not
|
||||
# exist until a theme has been imported. Naming it directly meant a machine
|
||||
# that had just installed the package set no wallpaper at all.
|
||||
# - The wait for the daemon was `until awww query; do sleep 0.2; done`, with
|
||||
# nothing to stop it. awww is a Recommends and lives in a COPR, so on a
|
||||
# machine that never enabled that repository the loop spun at 5 Hz for the
|
||||
# length of the session.
|
||||
#
|
||||
# Both are now bounded and both say so on the session log.
|
||||
|
||||
set -eu
|
||||
|
||||
link="${XDG_DATA_HOME:-$HOME/.local/share}/wallpapers/hyprcosmic/current"
|
||||
|
||||
# Searched in order when the link is not there yet. The first is cosmic-wallpapers,
|
||||
# which the Fedora package recommends for exactly this; the other two are where
|
||||
# freedesktop and KDE put theirs, so a machine with neither still has a chance.
|
||||
fallbacks='/usr/share/backgrounds/cosmic /usr/share/backgrounds /usr/share/wallpapers'
|
||||
|
||||
if ! command -v awww >/dev/null 2>&1; then
|
||||
echo "hyprcosmic-wallpaper: awww is not installed; no wallpaper set" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# awww-daemon is started immediately above this in autostart and has only just
|
||||
# been forked, so an immediate `awww img` loses the race and fails silently.
|
||||
# Ten seconds is far longer than the daemon has ever taken and short enough that
|
||||
# a daemon which is never coming does not leave a process spinning all session.
|
||||
tries=0
|
||||
until awww query >/dev/null 2>&1; do
|
||||
tries=$((tries + 1))
|
||||
if [ "$tries" -ge 50 ]; then
|
||||
echo "hyprcosmic-wallpaper: awww-daemon did not answer in 10s; no wallpaper set" >&2
|
||||
exit 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# -e rather than -L, deliberately: it follows the link, so a `current` left
|
||||
# dangling by a deleted theme falls through to the defaults instead of being
|
||||
# handed to awww as a path that is not there.
|
||||
img=''
|
||||
if [ -e "$link" ]; then
|
||||
img=$link
|
||||
else
|
||||
for dir in $fallbacks; do
|
||||
[ -d "$dir" ] || continue
|
||||
for candidate in "$dir"/*.jpg "$dir"/*.jpeg "$dir"/*.png; do
|
||||
# The glob is literal when nothing matches, which is why
|
||||
# this tests the file rather than trusting the expansion.
|
||||
[ -f "$candidate" ] || continue
|
||||
img=$candidate
|
||||
break
|
||||
done
|
||||
# An if, not `[ -n "$img" ] && break`: under set -e a trailing
|
||||
# false at the end of the loop body would take the script with it.
|
||||
if [ -n "$img" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$img" ]; then
|
||||
echo "hyprcosmic-wallpaper: no $link yet, falling back to $img" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$img" ]; then
|
||||
echo "hyprcosmic-wallpaper: no wallpaper found in $link or $fallbacks" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec awww img "$img"
|
||||
@@ -0,0 +1,206 @@
|
||||
# HyprCosmic configuration, in Hyprland's idiom.
|
||||
#
|
||||
# Compiled into COSMIC's config tree by `cosmic-conf apply`. The file wins:
|
||||
# every key here overwrites whatever COSMIC's own settings UI last stored, so
|
||||
# edit this rather than the GUI for anything it covers.
|
||||
|
||||
# --- Theme --------------------------------------------------------------
|
||||
#
|
||||
# `cosmic-conf import-theme <theme>/hypr.theme --out ~/.config/hyprcosmic/theme.conf`
|
||||
# turns a HyDE theme into conf keys. Sourcing it rather than pasting it in
|
||||
# keeps the two apart: re-importing overwrites theme.conf and cannot touch the
|
||||
# keybindings below, and anything you want to override can simply be repeated
|
||||
# later in this file, since the last assignment to a key wins.
|
||||
#
|
||||
# Commented out because a `source` pointing at a file that does not exist is a
|
||||
# hard error, and no theme is imported yet. Uncomment it once you have run the
|
||||
# command above -- import-theme will remind you.
|
||||
#
|
||||
# source = ~/.config/hyprcosmic/theme.conf
|
||||
|
||||
$mainMod = SUPER
|
||||
|
||||
# --- Tiling -------------------------------------------------------------
|
||||
#
|
||||
# COSMIC ships with autotile off, so a new window opens floating, at whatever
|
||||
# size the application asked for, on top of what you were already looking at.
|
||||
# This is the setting that makes window placement automatic: each new window
|
||||
# takes a share of the screen instead.
|
||||
#
|
||||
# It applies immediately and everywhere. `autotile_behavior` is a separate
|
||||
# COSMIC key that defaults to Global, which retiles workspaces that already
|
||||
# exist; the other value, PerWorkspace, arms only workspaces created from now
|
||||
# on. There is no conf key for it because the default is the one worth having
|
||||
# -- if you want the other, set it in cosmic-settings and this file will not
|
||||
# fight you over it.
|
||||
#
|
||||
# Super+Y still toggles tiling for the current workspace on its own, so a
|
||||
# workspace you want to keep floating does not need this turned off.
|
||||
#
|
||||
# Gaps are here rather than in the theme block because they are only visible
|
||||
# once windows tile: with autotile off nothing is laid out, so nothing has a
|
||||
# gap. gaps_out is doubled so the screen edge reads as deliberate margin
|
||||
# rather than as one more seam.
|
||||
$gap = 4
|
||||
|
||||
general {
|
||||
autotile = true
|
||||
preserve_split = true
|
||||
gaps_in = $gap
|
||||
gaps_out = $gap * 2
|
||||
}
|
||||
|
||||
# --- Workspaces ----------------------------------------------------------
|
||||
#
|
||||
# COSMIC's workspaces are dynamic and there is no setting that turns that off:
|
||||
# it keeps exactly one trailing empty workspace and collects every other empty
|
||||
# one the moment its last window closes. So "workspace 4 is the browser" is only
|
||||
# true while the browser is open, and the number moves under you as workspaces
|
||||
# come and go.
|
||||
#
|
||||
# These lines declare a fixed set instead, which is what a Hyprland user expects.
|
||||
# A declared workspace is pinned, and pinning is precisely what exempts it from
|
||||
# that collection, so it stays at its number whether or not anything is on it.
|
||||
#
|
||||
# The dynamic workspace is not lost. Declaring four leaves you with 1-4 always
|
||||
# present and a fifth appearing the moment you use it, then a sixth after that,
|
||||
# exactly as Hyprland does.
|
||||
#
|
||||
# Two things worth knowing before you edit:
|
||||
#
|
||||
# * The index is required and workspaces below it are created too, because
|
||||
# COSMIC restores them by position rather than by number. `workspace = 4`
|
||||
# on its own gives you four workspaces, three of them unnamed.
|
||||
# * This is the one key in this file that is not live. The compositor reads
|
||||
# it once at startup, so an edit applies at your next login. Everything
|
||||
# else here takes effect as soon as you save.
|
||||
#
|
||||
# `name:` is what waybar and anything else reading ext-workspace will show.
|
||||
# `tiling:` overrides `general:autotile` for that workspace alone; leave it out
|
||||
# and the workspace follows the setting above. Hyprland's `monitor:` is not
|
||||
# accepted -- COSMIC matches a workspace to a monitor by EDID rather than by
|
||||
# name, and there is no way to write an EDID down here. Saying so is better
|
||||
# than accepting the parameter and quietly ignoring it.
|
||||
#
|
||||
# Commented out because a fixed set is a preference, not an improvement, and
|
||||
# leaving these off gives you COSMIC's stock behaviour. Uncomment to opt in.
|
||||
#
|
||||
# workspace = 1, name:term
|
||||
# workspace = 2, name:web
|
||||
# workspace = 3, name:code
|
||||
# workspace = 4, name:chat
|
||||
|
||||
# --- Window rules --------------------------------------------------------
|
||||
#
|
||||
# Where an application opens, decided from what it is rather than from where
|
||||
# you happened to be standing. `windowrule = workspace 4, class:^(vivaldi)$`
|
||||
# means the browser lands on workspace 4 no matter which one is in front of you.
|
||||
#
|
||||
# Rules are matched once, as the window opens, and the first one that matches
|
||||
# wins -- so put the specific ones above the general ones. `class:` and `title:`
|
||||
# are regular expressions; give both and both have to match. A rule with neither
|
||||
# is refused, since it would match every window in the session.
|
||||
#
|
||||
# These pair with the workspace lines above. `workspace name:web` is worth
|
||||
# preferring over `workspace 2`: a name follows the workspace if you renumber
|
||||
# it, and only exists because you declared it, so a typo fails loudly rather
|
||||
# than sending the window to whatever happens to be second.
|
||||
#
|
||||
# A rule never switches you to the workspace it used -- Hyprland's `silent`,
|
||||
# always on. The word is accepted so pasted-in configs keep working.
|
||||
#
|
||||
# Only `workspace` is supported. Hyprland's float, size, move, opacity and the
|
||||
# rest have nothing in COSMIC to project onto, and matchers that ask about
|
||||
# window state -- floating:, fullscreen:, onworkspace: -- cannot be answered at
|
||||
# the moment a window opens. Both fail with an explanation rather than parsing
|
||||
# and doing nothing. Floating for a particular application is a tiling
|
||||
# exception, which lives in COSMIC's own settings rather than in this file.
|
||||
#
|
||||
# Unlike the workspace lines, these are live: save the file and the next window
|
||||
# to open obeys them.
|
||||
#
|
||||
# windowrule = workspace name:web, class:^(vivaldi|firefox)$
|
||||
# windowrule = workspace name:code, class:^(codium|code)$
|
||||
# windowrule = workspace name:chat, class:^(discord|Element)$
|
||||
# windowrule = workspace 1, class:^(kitty)$, title:^(dev)$
|
||||
|
||||
# --- Input ---------------------------------------------------------------
|
||||
#
|
||||
# Focus follows the mouse, which COSMIC supports but ships turned off. Hyprland
|
||||
# spells it `input:follow_mouse`, and that spelling is what this file accepts;
|
||||
# `general:focus_follows_cursor` is the same setting under COSMIC's own name,
|
||||
# and setting both is not an error -- whichever comes last in the file wins.
|
||||
#
|
||||
# Autoraise comes with it and is not a separate key. cosmic-comp raises a
|
||||
# window as part of focusing it, so a floating window under the pointer comes
|
||||
# to the front on its own. Tiled windows do not overlap, so there is nothing
|
||||
# there to raise.
|
||||
#
|
||||
# The delay is what stops the focus from skating across every window between
|
||||
# where the pointer started and where it stopped -- moving the mouse to a menu
|
||||
# on the far side of the screen should not hand focus to whatever it crossed on
|
||||
# the way. 250ms is COSMIC's own default and is kept rather than shortened,
|
||||
# because the failure it prevents is more annoying than the wait.
|
||||
#
|
||||
# Only 0 and 1 mean anything here. Hyprland's 2 and 3 separate pointer focus
|
||||
# from keyboard focus, which cosmic-comp cannot do -- it has one focus. Those
|
||||
# values are rejected with an explanation rather than rounded to 1.
|
||||
input {
|
||||
follow_mouse = 1
|
||||
follow_mouse_delay = 250
|
||||
}
|
||||
|
||||
# --- Launcher -----------------------------------------------------------
|
||||
#
|
||||
# The hyprcosmic profile does not start cosmic-launcher or cosmic-app-library,
|
||||
# which leaves COSMIC's stock Super, Super+/ and Super+A bindings pointing at
|
||||
# nothing. These take them over with rofi. Written to the Shortcuts `custom`
|
||||
# key, which cosmic-comp merges over `defaults`, so the system file is not
|
||||
# touched and reverting is a matter of deleting these lines and re-applying.
|
||||
|
||||
# Tap Super on its own to open the launcher.
|
||||
# The key field is deliberately empty: COSMIC supports
|
||||
# modifier-only bindings, which Hyprland's `bind` cannot express.
|
||||
bind = $mainMod, , exec, rofi -show drun
|
||||
bind = $mainMod, slash, exec, rofi -show drun
|
||||
bind = $mainMod, A, exec, rofi -show drun
|
||||
|
||||
# Was System(WorkspaceOverview); cosmic-workspaces is not running either.
|
||||
# rofi's window mode is the nearest thing that still shows every open window.
|
||||
bind = $mainMod, W, exec, rofi -show window
|
||||
|
||||
# Terminal, in the Hyprland idiom. Super+T also still works — cosmic-comp
|
||||
# handles System(Terminal) itself, so that binding never went dead.
|
||||
bind = $mainMod, Return, exec, cosmic-term
|
||||
|
||||
# --- Session ------------------------------------------------------------
|
||||
#
|
||||
# The hyprcosmic profile disables cosmic-panel, and COSMIC's power applet lives
|
||||
# in that panel, so a HyprCosmic session had no logout, reboot or shutdown
|
||||
# anywhere -- the only way out was `systemctl reboot` from a terminal. This is
|
||||
# that way out. The same script backs waybar's power button, so the two cannot
|
||||
# drift apart, and it asks for confirmation before anything that ends the
|
||||
# session.
|
||||
#
|
||||
# Super+Shift+E is Hyprland's own spelling for "exit". The menu also offers
|
||||
# lock and suspend, which is why the binding is not named after logout.
|
||||
bind = $mainMod SHIFT, E, exec, hyprcosmic-powermenu
|
||||
|
||||
# --- Help ---------------------------------------------------------------
|
||||
#
|
||||
# Every shortcut in the session, in one searchable window, with the command
|
||||
# each one runs. cosmic-settings can show these, but it is where you go to
|
||||
# change a binding, not where you go to remember one -- and the hyprcosmic
|
||||
# profile does not put it one click away.
|
||||
#
|
||||
# The script reads COSMIC's own Shortcuts files rather than this one. That is
|
||||
# deliberate and it matters: this file declares six bindings and the session
|
||||
# answers to 122, the rest being COSMIC defaults there is no reason to restate
|
||||
# here. A reference built from this file alone would look complete and be
|
||||
# missing every window, workspace and media key on the machine.
|
||||
#
|
||||
# Super+Shift+/ is Super+? on this keyboard, which is the usual spelling for
|
||||
# help, and it is one of the few chords in that corner COSMIC leaves free --
|
||||
# Super+K and Super+I are both focus actions in the defaults. The same script
|
||||
# backs waybar's keyboard button.
|
||||
bind = $mainMod SHIFT, slash, exec, hyprcosmic-keybinds
|
||||
@@ -0,0 +1,50 @@
|
||||
/* rofi configuration and theme entry point for a HyprCosmic session.
|
||||
*
|
||||
* Below the configuration block this file is only an import list, because the
|
||||
* order is the whole design:
|
||||
*
|
||||
* 1. palette.rasi default colours, under the names HyDE themes use
|
||||
* 2. theme.rasi the installed HyDE theme's rofi.theme, if any
|
||||
* 3. rules.rasi geometry and layout; no colour literals at all
|
||||
* 4. local.rasi per-machine bits: the sidebar wallpaper, the icon theme
|
||||
*
|
||||
* Imports are read strictly in sequence and a later definition of a property
|
||||
* wins over an earlier one. That is what lets step 2 recolour the launcher
|
||||
* without step 3 knowing a theme exists, and step 4 point at a wallpaper
|
||||
* without either of them knowing the path.
|
||||
*
|
||||
* There is no bridge step, unlike waybar: a HyDE rofi.theme defines exactly the
|
||||
* names rules.rasi already references, so nothing needs mapping.
|
||||
*
|
||||
* Steps 2 and 4 are relative imports, which rofi resolves against the directory
|
||||
* of the importing file. That is why this file belongs at
|
||||
* ~/.config/rofi/config.rasi rather than under /usr/share: both are per-user.
|
||||
* It is also the only name rofi loads on its own, so `rofi -show drun` from a
|
||||
* keybinding picks all of this up with no arguments and no launcher script.
|
||||
*
|
||||
* Both relative imports must exist. A missing @import is an error rofi reports
|
||||
* in place of the launcher, not a warning it skips, so `cosmic-conf` writes
|
||||
* theme.rasi and local.rasi even when there is nothing to put in them -- empty,
|
||||
* in which case the defaults from step 1 stand.
|
||||
*/
|
||||
|
||||
configuration {
|
||||
modi: "drun,run,window,filebrowser";
|
||||
show-icons: true;
|
||||
|
||||
/* Nerd Font glyphs, as HyDE's style_1 has them: apps, terminal, files,
|
||||
* windows. They render as tofu without a Nerd Font installed; see the font
|
||||
* list in rules.rasi. */
|
||||
display-drun: " ";
|
||||
display-run: " ";
|
||||
display-filebrowser: " ";
|
||||
display-window: " ";
|
||||
|
||||
drun-display-format: "{name}";
|
||||
window-format: "{w}{t}";
|
||||
}
|
||||
|
||||
@import "/usr/share/hyprcosmic/rofi/palette.rasi"
|
||||
@import "theme.rasi"
|
||||
@import "/usr/share/hyprcosmic/rofi/rules.rasi"
|
||||
@import "local.rasi"
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Per-machine launcher settings for HyprCosmic.
|
||||
*
|
||||
* Empty until `hyprcosmic-conf import-theme --assets` writes it. It exists from
|
||||
* the first session because config.rasi imports it, and a missing @import is an
|
||||
* error rofi reports in place of the launcher rather than a warning it skips.
|
||||
*
|
||||
* It is the last of the four imports in config.rasi, so anything here wins; it
|
||||
* is also overwritten by the next import run with --overwrite, so keep hand
|
||||
* edits somewhere else.
|
||||
*
|
||||
* Two things belong in this file and nothing else does: values that name a path
|
||||
* or a package on this particular machine, which neither /usr/share/hyprcosmic
|
||||
* nor a HyDE theme file can know. Concretely, that is the icon theme and the
|
||||
* sidebar image:
|
||||
*
|
||||
* configuration {
|
||||
* icon-theme: "Tela-circle-purple", "Adwaita";
|
||||
* }
|
||||
*
|
||||
* dummywall {
|
||||
* background-image: url("/home/you/.local/share/wallpapers/hyprcosmic/current", height);
|
||||
* }
|
||||
*
|
||||
* Both are shown commented out rather than set, because a default that named
|
||||
* an icon theme nobody has installed, or a wallpaper path under someone else's
|
||||
* home, would be worse than no default at all.
|
||||
*/
|
||||
|
||||
* {}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* Default colours for the HyprCosmic launcher.
|
||||
*
|
||||
* Only one name set is needed here, unlike waybar's palette.css. A HyDE theme's
|
||||
* rofi.theme is a list of exactly these names -- main-bg, select-fg and so on --
|
||||
* and rules.rasi references the same names directly, so there is nothing to
|
||||
* bridge between. Importing a theme after this file simply re-points them.
|
||||
*
|
||||
* Defining them here is what makes a missing theme harmless: rules.rasi always
|
||||
* resolves against something, so an unthemed launcher renders with these values
|
||||
* instead of every widget falling back to rofi's stock grey.
|
||||
*
|
||||
* Values are Tokyo Night, matching cosmic-conf's own defaults.
|
||||
*/
|
||||
|
||||
* {
|
||||
/* Window and text. main-br is the window border, main-ex an accent HyDE
|
||||
* uses for secondary text; rofi themes vary in how much of it they set. */
|
||||
main-bg: #1a1b26e6;
|
||||
main-fg: #c0caf5ff;
|
||||
main-br: #bb9af7ff;
|
||||
main-ex: #7dcfffff;
|
||||
|
||||
/* The highlighted row. */
|
||||
select-bg: #7aa2f7ff;
|
||||
select-fg: #1a1b26ff;
|
||||
|
||||
/* Named by HyDE's themes but unused by this layout. Defined so a theme that
|
||||
* sets them parses, and so one that does not still resolves. */
|
||||
separatorcolor: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/* Geometry and layout for the HyprCosmic launcher.
|
||||
*
|
||||
* Adapted from HyDE's style_1 ("Background image with a sidebar list"), which
|
||||
* is the layout its rofilaunch.sh uses by default. Two deliberate differences:
|
||||
*
|
||||
* - HyDE injects the border radii, border width and font at the command line,
|
||||
* as three -theme-str arguments computed from Hyprland's own gaps and
|
||||
* border settings. We have no launcher script -- the keybinding in
|
||||
* cosmic.conf runs plain `rofi -show drun` -- so those values are written
|
||||
* out here, at the defaults rofilaunch.sh would have produced.
|
||||
* - The sidebar image lives in local.rasi rather than here, because the
|
||||
* wallpaper path is per-user. HyDE points at ~/.cache/hyde/wall.thmb, a
|
||||
* thumbnail its wallpaper scripts generate; we do not run those, so
|
||||
* pointing there would render an empty panel on every machine.
|
||||
*
|
||||
* No colour literals: every colour is a name from palette.rasi, which a theme
|
||||
* may have re-pointed. That separation is what lets `cosmic-conf import-theme`
|
||||
* recolour the launcher without touching a line of layout.
|
||||
*/
|
||||
|
||||
/* Pango takes a comma-separated family list, so the Nerd Font is a preference
|
||||
* rather than a requirement -- the glyphs in config.rasi's mode labels need it,
|
||||
* but everything else still renders without it. */
|
||||
* {
|
||||
font: "JetBrainsMono Nerd Font, Noto Sans Mono 10";
|
||||
}
|
||||
|
||||
|
||||
// Main //
|
||||
window {
|
||||
height: 33em;
|
||||
width: 63em;
|
||||
transparency: "real";
|
||||
fullscreen: false;
|
||||
enabled: true;
|
||||
cursor: "default";
|
||||
spacing: 0em;
|
||||
padding: 0em;
|
||||
border: 2px;
|
||||
border-radius: 30px;
|
||||
border-color: @main-br;
|
||||
background-color: @main-bg;
|
||||
}
|
||||
mainbox {
|
||||
enabled: true;
|
||||
spacing: 0em;
|
||||
padding: 0em;
|
||||
orientation: horizontal;
|
||||
children: [ "dummywall" , "listbox" ];
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* The sidebar. Solid colour here so that a machine with no wallpaper override
|
||||
* gets a panel in the theme's own background, not a hole. local.rasi paints an
|
||||
* image over the top when there is one. */
|
||||
dummywall {
|
||||
spacing: 0em;
|
||||
padding: 0em;
|
||||
width: 37em;
|
||||
expand: false;
|
||||
orientation: horizontal;
|
||||
children: [ "mode-switcher" , "inputbar" ];
|
||||
background-color: @main-bg;
|
||||
}
|
||||
|
||||
|
||||
// Modes //
|
||||
mode-switcher {
|
||||
orientation: vertical;
|
||||
enabled: true;
|
||||
width: 3.8em;
|
||||
padding: 9.2em 0.5em 9.2em 0.5em;
|
||||
spacing: 1.2em;
|
||||
background-color: transparent;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
border-radius: 2em;
|
||||
background-color: @main-bg;
|
||||
text-color: @main-fg;
|
||||
}
|
||||
button selected {
|
||||
background-color: @main-fg;
|
||||
text-color: @main-bg;
|
||||
}
|
||||
|
||||
|
||||
// Inputs //
|
||||
/* The entry is hidden: typing filters the list without a visible prompt, which
|
||||
* is what gives style_1 its uncluttered look. inputbar still has to exist for
|
||||
* keystrokes to reach the filter. */
|
||||
inputbar {
|
||||
enabled: true;
|
||||
children: [ "entry" ];
|
||||
background-color: transparent;
|
||||
}
|
||||
entry {
|
||||
enabled: false;
|
||||
}
|
||||
|
||||
|
||||
// Lists //
|
||||
listbox {
|
||||
spacing: 0em;
|
||||
padding: 2em;
|
||||
children: [ "dummy" , "listview" , "dummy" ];
|
||||
background-color: transparent;
|
||||
}
|
||||
listview {
|
||||
enabled: true;
|
||||
spacing: 0em;
|
||||
padding: 0em;
|
||||
columns: 1;
|
||||
lines: 8;
|
||||
cycle: true;
|
||||
dynamic: true;
|
||||
scrollbar: false;
|
||||
layout: vertical;
|
||||
reverse: false;
|
||||
expand: false;
|
||||
fixed-height: true;
|
||||
fixed-columns: true;
|
||||
cursor: "default";
|
||||
background-color: transparent;
|
||||
text-color: @main-fg;
|
||||
}
|
||||
dummy {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
// Elements //
|
||||
element {
|
||||
enabled: true;
|
||||
spacing: 0.8em;
|
||||
padding: 0.4em 0.4em 0.4em 1.5em;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
text-color: @main-fg;
|
||||
}
|
||||
element selected.normal {
|
||||
background-color: @select-bg;
|
||||
text-color: @select-fg;
|
||||
}
|
||||
element-icon {
|
||||
size: 2.8em;
|
||||
cursor: inherit;
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
}
|
||||
element-text {
|
||||
vertical-align: 0.5;
|
||||
horizontal-align: 0.0;
|
||||
cursor: inherit;
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
}
|
||||
|
||||
|
||||
// Error message //
|
||||
error-message {
|
||||
text-color: @main-fg;
|
||||
background-color: @main-bg;
|
||||
text-transform: capitalize;
|
||||
children: [ "textbox" ];
|
||||
}
|
||||
textbox {
|
||||
text-color: inherit;
|
||||
background-color: inherit;
|
||||
vertical-align: 0.5;
|
||||
horizontal-align: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* Step 2 of the import chain in config.rasi: the installed HyDE theme's rofi
|
||||
* colours.
|
||||
*
|
||||
* Empty on purpose, for the same reason as waybar's theme.css: a missing
|
||||
* @import is an error rofi reports in place of the launcher rather than a
|
||||
* warning it skips, so config.rasi can only import a file that is guaranteed
|
||||
* to exist. palette.rasi already defines every name rules.rasi reads, so
|
||||
* defining nothing here leaves a complete theme rather than an unresolved
|
||||
* reference.
|
||||
*
|
||||
* `hyprcosmic-conf import-theme` overwrites this with the imported theme's
|
||||
* rofi colours. Hand-written overrides belong in local.rasi, which is imported
|
||||
* after the rules and is left alone unless --overwrite is passed.
|
||||
*/
|
||||
|
||||
* {}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Map HyDE's waybar colour names onto the ones rules.css uses.
|
||||
*
|
||||
* Imported after both palette.css and the theme's own waybar.theme, so it sees
|
||||
* whichever definition of each HyDE name is in force and does not care which
|
||||
* file supplied it. This indirection is the whole reason a HyDE theme can
|
||||
* recolour this bar without any of the rules changing.
|
||||
*/
|
||||
|
||||
@define-color bar-fg @main-fg;
|
||||
@define-color accent @wb-act-bg;
|
||||
@define-color muted @wb-hvr-fg;
|
||||
|
||||
/* `bar-bg` IS remapped, and this is a deliberate departure from the theme.
|
||||
*
|
||||
* HyDE themes set bar-bg to something like rgba(0, 0, 0, 0.1) and rely on the
|
||||
* compositor blurring whatever is behind the bar. cosmic-comp has no
|
||||
* rule-driven blur -- `import-theme --report` lists decoration.blur.* as
|
||||
* needing a compositor patch -- so honouring that value literally gives a
|
||||
* ~90% transparent bar with unblurred desktop showing through and text that
|
||||
* cannot be read.
|
||||
*
|
||||
* So the theme's own background colour is used at an opacity that works
|
||||
* without blur. Delete these two lines to get the theme's literal value back,
|
||||
* or once blur lands.
|
||||
*/
|
||||
@define-color bar-bg alpha(@main-bg, 0.85);
|
||||
|
||||
/* The pill behind each module. Derived rather than themed: HyDE has no name
|
||||
* for it, because in HyDE the pill IS the bar -- its modules sit on a
|
||||
* transparent strip and the compositor blurs the desktop behind them. Without
|
||||
* blur that reads as text floating on the wallpaper, so here the bar keeps a
|
||||
* background and the pills are a light lift off it.
|
||||
*
|
||||
* Derived from the foreground, not the background, so it stays visible whether
|
||||
* the theme is dark or light. */
|
||||
@define-color module-bg alpha(@main-fg, 0.08);
|
||||
@@ -0,0 +1,427 @@
|
||||
// Waybar configuration for a HyprCosmic session.
|
||||
//
|
||||
// Waybar's shipped default (/etc/xdg/waybar/config.jsonc) is built entirely
|
||||
// around sway/* modules and shows nothing at all under cosmic-comp, so this
|
||||
// exists rather than a handful of overrides.
|
||||
//
|
||||
// Module choices are constrained by what cosmic-comp actually advertises on the
|
||||
// Wayland registry, which was checked against a live session:
|
||||
//
|
||||
// ext/workspaces ext_workspace_manager_v1 -- advertised by stock cosmic-comp.
|
||||
// wlr/taskbar zwlr_foreign_toplevel_management_v1 -- NOT in stock
|
||||
// cosmic-comp; provided by the HyprCosmic fork's Patch A.
|
||||
// Under a stock compositor this module stays empty and the
|
||||
// rest of the bar still works.
|
||||
// tray Needs a StatusNotifierWatcher. cosmic-panel normally
|
||||
// supplies one via cosmic-applet-status-area, and the
|
||||
// hyprcosmic profile disables the panel, so waybar hosts the
|
||||
// watcher itself here.
|
||||
//
|
||||
// ICONS. Every glyph below is a \uXXXX escape, never a literal character, and
|
||||
// this file is generated from config.jsonc.in for exactly that reason:
|
||||
//
|
||||
// - These are Private Use Area codepoints. They survive being copied, but
|
||||
// anything that re-types rather than copies them silently flattens them to
|
||||
// spaces or drops them next to a neighbour, and the damage is invisible in
|
||||
// a diff and indistinguishable from a font problem at runtime.
|
||||
// - An escape is plain ASCII, so it cannot be damaged that way, and it names
|
||||
// the intended glyph instead of leaving a box you have to identify.
|
||||
//
|
||||
// Every codepoint was checked against the installed JetBrainsMono Nerd Font
|
||||
// with `fc-list :charset=<cp>` before use. That check is worth doing rather
|
||||
// than copying HyDE's values: HyDE's pulseaudio module uses U+FA80 for the
|
||||
// muted state, an old Material Design Icons codepoint that Nerd Fonts v3
|
||||
// moved. It is absent from the installed font and would render as tofu, so
|
||||
// U+F075F is used instead.
|
||||
//
|
||||
// NOT INCLUDED, deliberately:
|
||||
//
|
||||
// backlight The only device is acpi_video0, a firmware stub reporting
|
||||
// brightness 49 of max 49 -- permanently 100% -- whose sysfs
|
||||
// node is not writable by this user, with brightnessctl absent.
|
||||
// The module would render a readout that never changes and a
|
||||
// scroll action that never works: visible, plausible, and dead.
|
||||
// That is the same failure class as the `pavucontrol` click this
|
||||
// file used to carry, and it is not worth re-introducing for a
|
||||
// percentage that is always the same number.
|
||||
{
|
||||
"layer": "top",
|
||||
"position": "top",
|
||||
"height": 34,
|
||||
"spacing": 4,
|
||||
|
||||
"modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
|
||||
"modules-center": ["mpris", "clock", "privacy"],
|
||||
"modules-right": [
|
||||
"systemd-failed-units",
|
||||
"idle_inhibitor",
|
||||
"custom/swaync",
|
||||
"bluetooth",
|
||||
"pulseaudio",
|
||||
"network",
|
||||
"disk",
|
||||
"temperature",
|
||||
"temperature#gpu",
|
||||
"custom/fan",
|
||||
"cpu",
|
||||
"memory",
|
||||
"power-profiles-daemon",
|
||||
"battery",
|
||||
"tray",
|
||||
"custom/power"
|
||||
],
|
||||
|
||||
// Opens the keyboard reference. First on the bar because it is the thing
|
||||
// you look for when you do not yet know where anything is.
|
||||
//
|
||||
// No "exec": this is a button, not a readout. A custom module with only a
|
||||
// format and an on-click runs nothing on an interval, so it costs a static
|
||||
// glyph and nothing else.
|
||||
//
|
||||
// The same script is on Super + Shift + / -- the binding is named in the
|
||||
// tooltip because a help button you have to reach for with the mouse has
|
||||
// not finished helping.
|
||||
"custom/keybinds": {
|
||||
"format": "\udb80\udf0c",
|
||||
"tooltip": true,
|
||||
"tooltip-format": "keyboard shortcuts\nSuper + Shift + /",
|
||||
"on-click": "hyprcosmic-keybinds"
|
||||
},
|
||||
|
||||
"ext/workspaces": {
|
||||
"format": "{name}",
|
||||
"on-click": "activate"
|
||||
},
|
||||
|
||||
"wlr/taskbar": {
|
||||
"format": "{icon}",
|
||||
"icon-size": 18,
|
||||
"tooltip-format": "{title}",
|
||||
"on-click": "activate",
|
||||
"on-click-middle": "close"
|
||||
},
|
||||
|
||||
// The taskbar above shows which windows exist; this shows which one has the
|
||||
// keyboard. Nothing else on the bar answered that.
|
||||
//
|
||||
// This is the one module here that talks to the fork's Hyprland IPC rather
|
||||
// than to a Wayland protocol, and it needs no compositor change: waybar
|
||||
// reads `j/activewindow` on startup and then follows `activewindow>>` on
|
||||
// .socket2.sock, both of which hypr_ipc already serves. A retitle of the
|
||||
// focused window propagates too -- src/hypr_ipc/sync.rs snapshots the
|
||||
// focused window as (class, title) and emits on any difference, which is
|
||||
// why upstream's separate `windowtitle` event is not needed here. Upstream
|
||||
// Hyprland emits from call sites and so must announce that case
|
||||
// separately; this fork diffs state on a 150 ms tick and cannot miss it.
|
||||
//
|
||||
// `separate-outputs` is left off deliberately: there is one bar on one
|
||||
// output, and with it on, the module reports that monitor's active window
|
||||
// rather than the focused one, which on a single head is the same answer by
|
||||
// a longer route.
|
||||
"hyprland/window": {
|
||||
"format": "\uf2d0 {title}",
|
||||
"max-length": 60,
|
||||
"tooltip": true,
|
||||
"tooltip-format": "{title}\n\nclass: {class}",
|
||||
// Titles are written for a window's own title bar, not for a 60-column
|
||||
// slot on a bar, so the worst offenders are trimmed to the part that
|
||||
// identifies the window. ECMAScript regex; the value is a replacement
|
||||
// string where $1 is the first capture.
|
||||
//
|
||||
// The separator is an em dash, and it is written as an escape
|
||||
// for the same reason every glyph in this file is: the template is read
|
||||
// as ASCII and the generator refuses to emit anything that is not, so a
|
||||
// literal em dash pasted here fails the build rather than reaching the
|
||||
// bar. COSMIC's applications really do use an em dash and not a hyphen
|
||||
// -- `j/clients` reports "dingo@fedora:~ <em dash> COSMIC Terminal" -- so
|
||||
// a rule written with a hyphen matches nothing.
|
||||
//
|
||||
// There is deliberately no catch-all for the empty title. waybar's
|
||||
// documentation does not say whether a rule is applied on a full match
|
||||
// or a search, so an empty pattern is either "matches nothing but the
|
||||
// empty title" or "matches every title", and the difference is the
|
||||
// whole bar. The no-window case is handled in rules.css instead, the
|
||||
// same way #mpris already handles having nothing to say.
|
||||
"rewrite": {
|
||||
"(.*) [-\u2014] Vivaldi": "$1",
|
||||
"(.*) [-\u2014] COSMIC Terminal": "$1",
|
||||
"(.*) [-\u2014] COSMIC Text Editor": "$1",
|
||||
"(.*) [-\u2014] COSMIC Files": "$1",
|
||||
"(.*) [-\u2014] Mozilla Firefox": "$1"
|
||||
}
|
||||
},
|
||||
|
||||
// Renders nothing at all when no player is running, which is what we want
|
||||
// from a centre module: it takes no space until there is something to say.
|
||||
// playerctl backs every action here and is installed.
|
||||
"mpris": {
|
||||
"format": "{player_icon} {dynamic}",
|
||||
"format-paused": "{status_icon} <i>{dynamic}</i>",
|
||||
"dynamic-order": ["title", "artist"],
|
||||
"dynamic-separator": " ",
|
||||
"max-length": 40,
|
||||
"interval": 1,
|
||||
"player-icons": { "default": "\uf001" },
|
||||
"status-icons": {
|
||||
"playing": "\uf04b",
|
||||
"paused": "\uf04c",
|
||||
"stopped": "\uf04d"
|
||||
},
|
||||
"on-click": "playerctl play-pause",
|
||||
"on-click-middle": "playerctl previous",
|
||||
"on-click-right": "playerctl next",
|
||||
"on-scroll-up": "playerctl position 5+",
|
||||
"on-scroll-down": "playerctl position 5-",
|
||||
"tooltip-format": "{title}\nby {artist}\n{position} / {length}\n\nplayer: {player}"
|
||||
},
|
||||
|
||||
"clock": {
|
||||
"format": "{:%a %d %b %H:%M}",
|
||||
"tooltip-format": "<tt><small>{calendar}</small></tt>"
|
||||
},
|
||||
|
||||
// Only appears while something is actually capturing. Nothing is ignored:
|
||||
// an exception list is how a privacy indicator stops being one.
|
||||
"privacy": {
|
||||
"icon-spacing": 6,
|
||||
"transition-duration": 200,
|
||||
"modules": [
|
||||
{ "type": "screenshare", "tooltip": true },
|
||||
{ "type": "audio-in", "tooltip": true }
|
||||
]
|
||||
},
|
||||
|
||||
// First on the right, and silent unless something is wrong. A unit that
|
||||
// failed at boot otherwise stays failed until the day you happen to run
|
||||
// `systemctl --failed` -- there is no other surface for it in a COSMIC
|
||||
// session, which has no equivalent of GNOME's abrt notification.
|
||||
//
|
||||
// Both scopes are watched. The user scope is where a session's own units
|
||||
// fail, which is the half that matters here and the half a system-only
|
||||
// check misses.
|
||||
"systemd-failed-units": {
|
||||
"format": "\udb80\udc26 {nr_failed}",
|
||||
"format-ok": "",
|
||||
"system": true,
|
||||
"user": true,
|
||||
"hide-on-ok": true,
|
||||
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
|
||||
},
|
||||
|
||||
"idle_inhibitor": {
|
||||
"format": "{icon}",
|
||||
"format-icons": {
|
||||
"activated": "\udb80\udd76",
|
||||
"deactivated": "\udb81\udeca"
|
||||
},
|
||||
"tooltip-format-activated": "Caffeine on -- idling and blanking suppressed",
|
||||
"tooltip-format-deactivated": "Caffeine off -- normal power settings apply"
|
||||
},
|
||||
|
||||
// swaync is this session's notification daemon: it owns
|
||||
// org.freedesktop.Notifications and is D-Bus activated through
|
||||
// swaync.service, so it needs no autostart entry and this module starts it
|
||||
// on first contact if nothing else has.
|
||||
//
|
||||
// It is here because the hyprcosmic profile disables cosmic-panel and
|
||||
// COSMIC's notification applet lives in that panel. Without this there is
|
||||
// no way to reach notification history at all.
|
||||
"custom/swaync": {
|
||||
"format": "{icon}",
|
||||
"format-icons": {
|
||||
"none": "\uf0f3",
|
||||
"notification": "\udb80\udc9a",
|
||||
"dnd-none": "\uf1f6",
|
||||
"dnd-notification": "\uf1f6",
|
||||
"inhibited-none": "\uf0f3",
|
||||
"inhibited-notification": "\udb80\udc9a",
|
||||
"dnd-inhibited-none": "\uf1f6",
|
||||
"dnd-inhibited-notification": "\uf1f6"
|
||||
},
|
||||
"return-type": "json",
|
||||
"exec": "swaync-client -swb",
|
||||
"on-click": "swaync-client -t -sw",
|
||||
"on-click-right": "swaync-client -d -sw",
|
||||
"tooltip": true,
|
||||
"escape": true
|
||||
},
|
||||
|
||||
// bluez is running with an unblocked hci0. The click target is COSMIC's own
|
||||
// settings page rather than blueman or overskride, neither of which is
|
||||
// installed here.
|
||||
"bluetooth": {
|
||||
"format": "\uf293",
|
||||
"format-disabled": "\uf294",
|
||||
"format-off": "\udb80\udcb2",
|
||||
"format-connected": "\uf293 {num_connections}",
|
||||
"tooltip-format": "{controller_alias}\n{num_connections} connected",
|
||||
"tooltip-format-connected": "{controller_alias}\n\n{device_enumerate}",
|
||||
"tooltip-format-enumerate-connected": "{device_alias}",
|
||||
"on-click": "cosmic-settings bluetooth"
|
||||
},
|
||||
|
||||
// The click used to be `pavucontrol`, which is not installed here: a button
|
||||
// that looked live and did nothing. cosmic-settings ships with the desktop,
|
||||
// so it cannot go missing the same way. Scroll and middle-click need no
|
||||
// helper -- waybar changes volume itself and wpctl comes with pipewire.
|
||||
"pulseaudio": {
|
||||
"format": "{icon} {volume}%",
|
||||
"format-muted": "\udb81\udf5f",
|
||||
"format-bluetooth": "{icon} {volume}%",
|
||||
"format-bluetooth-muted": "\udb81\udf5f",
|
||||
"format-icons": {
|
||||
"headphone": "\uf025",
|
||||
"headset": "\uf025",
|
||||
"hands-free": "\uf025",
|
||||
"phone": "\uf095",
|
||||
"car": "\uf1b9",
|
||||
"default": ["\uf026", "\uf027", "\uf028"]
|
||||
},
|
||||
"scroll-step": 5,
|
||||
"tooltip-format": "{desc}\n{icon} {volume}%",
|
||||
"on-click": "cosmic-settings sound",
|
||||
"on-click-middle": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
|
||||
},
|
||||
|
||||
"network": {
|
||||
"format-wifi": "\uf1eb {signalStrength}%",
|
||||
"format-ethernet": "\udb80\ude00 wired",
|
||||
"format-linked": "\udb80\ude00 {ifname}",
|
||||
"format-disconnected": "\udb82\udd2f offline",
|
||||
"tooltip-format": "{ifname}: {ipaddr}",
|
||||
"tooltip-format-wifi": "{essid} ({signalStrength}%)\n{ifname}: {ipaddr}",
|
||||
"on-click": "cosmic-settings network"
|
||||
},
|
||||
|
||||
// Root only. /home is on the same filesystem here, and a second entry for
|
||||
// it would be the same number twice.
|
||||
//
|
||||
// No warning threshold is set: waybar's disk module has no `states`, so the
|
||||
// colour comes from rules.css, which cannot see the percentage. The number
|
||||
// is the warning.
|
||||
"disk": {
|
||||
"path": "/",
|
||||
"format": "\udb80\udeca {percentage_used}%",
|
||||
"interval": 60,
|
||||
"tooltip-format": "{used} used of {total} on {path}, {free} free"
|
||||
},
|
||||
|
||||
// Tctl from k10temp, not thermal_zone0. The two read within a degree of
|
||||
// each other here, but the hwmon path names the CPU sensor explicitly.
|
||||
//
|
||||
// It is given as the PCI device's hwmon directory rather than
|
||||
// /sys/class/hwmon/hwmon6 because hwmonN numbering is assigned in probe
|
||||
// order and is not stable across boots. The PCI address is.
|
||||
"temperature": {
|
||||
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:18.3/hwmon",
|
||||
"input-filename": "temp1_input",
|
||||
"format": "\uf2ca {temperatureC} C",
|
||||
"critical-threshold": 90,
|
||||
"interval": 5,
|
||||
"tooltip-format": "CPU package: {temperatureC} C"
|
||||
},
|
||||
|
||||
// A second instance of the same module. `temperature#gpu` is waybar's alias
|
||||
// syntax: everything before the # selects the module, everything after is
|
||||
// just a name, and it becomes #temperature.gpu in CSS.
|
||||
//
|
||||
// The dGPU's own sensor, not the CPU's. On this machine the two diverge by
|
||||
// 30 degrees under load, so one reading cannot stand for both.
|
||||
//
|
||||
// temp1_label reads "edge" -- the die edge, which is the sensor amdgpu
|
||||
// exposes on this card. There is no junction or memory sensor here to
|
||||
// prefer over it.
|
||||
//
|
||||
// hwmon-path-abs is safe for this one: the PCI device has exactly one
|
||||
// hwmonN inside it, unlike the asus platform device that custom/fan below
|
||||
// has to work around.
|
||||
"temperature#gpu": {
|
||||
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon",
|
||||
"input-filename": "temp1_input",
|
||||
"format": "\udb82\udcae {temperatureC} C",
|
||||
"critical-threshold": 95,
|
||||
"interval": 5,
|
||||
"tooltip-format": "GPU edge: {temperatureC} C"
|
||||
},
|
||||
|
||||
// Speed of the fastest fan, with every fan in the tooltip. See the script
|
||||
// for why this is not a temperature module with a different divisor and not
|
||||
// an hwmon-path-abs: both were tried and neither can name this sensor.
|
||||
//
|
||||
// Emits nothing on a machine with no readable fan, and waybar draws an
|
||||
// empty custom module as nothing, so this costs no space on hardware that
|
||||
// has none.
|
||||
"custom/fan": {
|
||||
"exec": "hyprcosmic-fan",
|
||||
"return-type": "json",
|
||||
"format": "\udb80\ude10 {} rpm",
|
||||
"interval": 5
|
||||
},
|
||||
|
||||
"cpu": {
|
||||
"format": "\udb80\udf5b {usage}%",
|
||||
"interval": 5
|
||||
},
|
||||
|
||||
"memory": {
|
||||
"format": "\udb83\udee0 {percentage}%",
|
||||
"interval": 5,
|
||||
"tooltip-format": "{used:0.1f}G of {total:0.1f}G"
|
||||
},
|
||||
|
||||
// Backed by tuned-ppd, which owns net.hadess.PowerProfiles on this system.
|
||||
"power-profiles-daemon": {
|
||||
"format": "{icon}",
|
||||
"format-icons": {
|
||||
"default": "\uf0e7",
|
||||
"performance": "\uf135",
|
||||
"balanced": "\uf24e",
|
||||
"power-saver": "\uf06c"
|
||||
},
|
||||
"tooltip-format": "power profile: {profile}",
|
||||
"tooltip": true
|
||||
},
|
||||
|
||||
"battery": {
|
||||
"states": { "warning": 30, "critical": 15 },
|
||||
"format": "{icon} {capacity}%",
|
||||
"format-charging": "\uf1e6 {capacity}%",
|
||||
"format-plugged": "\uf1e6 {capacity}%",
|
||||
"format-icons": [
|
||||
"\udb80\udc8e",
|
||||
"\udb80\udc7a",
|
||||
"\udb80\udc7b",
|
||||
"\udb80\udc7c",
|
||||
"\udb80\udc7d",
|
||||
"\udb80\udc7e",
|
||||
"\udb80\udc7f",
|
||||
"\udb80\udc80",
|
||||
"\udb80\udc81",
|
||||
"\udb80\udc82",
|
||||
"\udb80\udc79"
|
||||
],
|
||||
"tooltip-format": "{timeTo}\n{power:0.1f}W",
|
||||
"on-click": "cosmic-settings power"
|
||||
},
|
||||
|
||||
"tray": { "icon-size": 18, "spacing": 8 },
|
||||
|
||||
// Last on the bar, because it is the one button here that can end the
|
||||
// session or the uptime and the far corner is the hardest place to hit by
|
||||
// accident.
|
||||
//
|
||||
// It is here for the same reason "custom/swaync" is: the hyprcosmic profile
|
||||
// disables cosmic-panel, and COSMIC's power applet lives in that panel.
|
||||
// Without this there is no logout, reboot or shutdown anywhere in the
|
||||
// session -- the only way out was `systemctl reboot` from a terminal.
|
||||
//
|
||||
// The click runs the same script as the $mainMod SHIFT E binding rather
|
||||
// than calling systemctl here, so the confirmation step cannot be bypassed
|
||||
// by using the mouse.
|
||||
"custom/power": {
|
||||
"format": "\uf011",
|
||||
"tooltip": true,
|
||||
"tooltip-format": "Lock, suspend, log out, reboot or shut down",
|
||||
"on-click": "hyprcosmic-powermenu"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// Waybar configuration for a HyprCosmic session.
|
||||
//
|
||||
// Waybar's shipped default (/etc/xdg/waybar/config.jsonc) is built entirely
|
||||
// around sway/* modules and shows nothing at all under cosmic-comp, so this
|
||||
// exists rather than a handful of overrides.
|
||||
//
|
||||
// Module choices are constrained by what cosmic-comp actually advertises on the
|
||||
// Wayland registry, which was checked against a live session:
|
||||
//
|
||||
// ext/workspaces ext_workspace_manager_v1 -- advertised by stock cosmic-comp.
|
||||
// wlr/taskbar zwlr_foreign_toplevel_management_v1 -- NOT in stock
|
||||
// cosmic-comp; provided by the HyprCosmic fork's Patch A.
|
||||
// Under a stock compositor this module stays empty and the
|
||||
// rest of the bar still works.
|
||||
// tray Needs a StatusNotifierWatcher. cosmic-panel normally
|
||||
// supplies one via cosmic-applet-status-area, and the
|
||||
// hyprcosmic profile disables the panel, so waybar hosts the
|
||||
// watcher itself here.
|
||||
//
|
||||
// ICONS. Every glyph below is a \uXXXX escape, never a literal character, and
|
||||
// this file is generated from config.jsonc.in for exactly that reason:
|
||||
//
|
||||
// - These are Private Use Area codepoints. They survive being copied, but
|
||||
// anything that re-types rather than copies them silently flattens them to
|
||||
// spaces or drops them next to a neighbour, and the damage is invisible in
|
||||
// a diff and indistinguishable from a font problem at runtime.
|
||||
// - An escape is plain ASCII, so it cannot be damaged that way, and it names
|
||||
// the intended glyph instead of leaving a box you have to identify.
|
||||
//
|
||||
// Every codepoint was checked against the installed JetBrainsMono Nerd Font
|
||||
// with `fc-list :charset=<cp>` before use. That check is worth doing rather
|
||||
// than copying HyDE's values: HyDE's pulseaudio module uses U+FA80 for the
|
||||
// muted state, an old Material Design Icons codepoint that Nerd Fonts v3
|
||||
// moved. It is absent from the installed font and would render as tofu, so
|
||||
// U+F075F is used instead.
|
||||
//
|
||||
// NOT INCLUDED, deliberately:
|
||||
//
|
||||
// backlight The only device is acpi_video0, a firmware stub reporting
|
||||
// brightness 49 of max 49 -- permanently 100% -- whose sysfs
|
||||
// node is not writable by this user, with brightnessctl absent.
|
||||
// The module would render a readout that never changes and a
|
||||
// scroll action that never works: visible, plausible, and dead.
|
||||
// That is the same failure class as the `pavucontrol` click this
|
||||
// file used to carry, and it is not worth re-introducing for a
|
||||
// percentage that is always the same number.
|
||||
{
|
||||
"layer": "top",
|
||||
"position": "top",
|
||||
"height": 34,
|
||||
"spacing": 4,
|
||||
|
||||
"modules-left": ["custom/keybinds", "ext/workspaces", "wlr/taskbar", "hyprland/window"],
|
||||
"modules-center": ["mpris", "clock", "privacy"],
|
||||
"modules-right": [
|
||||
"systemd-failed-units",
|
||||
"idle_inhibitor",
|
||||
"custom/swaync",
|
||||
"bluetooth",
|
||||
"pulseaudio",
|
||||
"network",
|
||||
"disk",
|
||||
"temperature",
|
||||
"temperature#gpu",
|
||||
"custom/fan",
|
||||
"cpu",
|
||||
"memory",
|
||||
"power-profiles-daemon",
|
||||
"battery",
|
||||
"tray",
|
||||
"custom/power"
|
||||
],
|
||||
|
||||
// Opens the keyboard reference. First on the bar because it is the thing
|
||||
// you look for when you do not yet know where anything is.
|
||||
//
|
||||
// No "exec": this is a button, not a readout. A custom module with only a
|
||||
// format and an on-click runs nothing on an interval, so it costs a static
|
||||
// glyph and nothing else.
|
||||
//
|
||||
// The same script is on Super + Shift + / -- the binding is named in the
|
||||
// tooltip because a help button you have to reach for with the mouse has
|
||||
// not finished helping.
|
||||
"custom/keybinds": {
|
||||
"format": "@@KEYBOARD@@",
|
||||
"tooltip": true,
|
||||
"tooltip-format": "keyboard shortcuts\nSuper + Shift + /",
|
||||
"on-click": "hyprcosmic-keybinds"
|
||||
},
|
||||
|
||||
"ext/workspaces": {
|
||||
"format": "{name}",
|
||||
"on-click": "activate"
|
||||
},
|
||||
|
||||
"wlr/taskbar": {
|
||||
"format": "{icon}",
|
||||
"icon-size": 18,
|
||||
"tooltip-format": "{title}",
|
||||
"on-click": "activate",
|
||||
"on-click-middle": "close"
|
||||
},
|
||||
|
||||
// The taskbar above shows which windows exist; this shows which one has the
|
||||
// keyboard. Nothing else on the bar answered that.
|
||||
//
|
||||
// This is the one module here that talks to the fork's Hyprland IPC rather
|
||||
// than to a Wayland protocol, and it needs no compositor change: waybar
|
||||
// reads `j/activewindow` on startup and then follows `activewindow>>` on
|
||||
// .socket2.sock, both of which hypr_ipc already serves. A retitle of the
|
||||
// focused window propagates too -- src/hypr_ipc/sync.rs snapshots the
|
||||
// focused window as (class, title) and emits on any difference, which is
|
||||
// why upstream's separate `windowtitle` event is not needed here. Upstream
|
||||
// Hyprland emits from call sites and so must announce that case
|
||||
// separately; this fork diffs state on a 150 ms tick and cannot miss it.
|
||||
//
|
||||
// `separate-outputs` is left off deliberately: there is one bar on one
|
||||
// output, and with it on, the module reports that monitor's active window
|
||||
// rather than the focused one, which on a single head is the same answer by
|
||||
// a longer route.
|
||||
"hyprland/window": {
|
||||
"format": "@@WINDOW@@ {title}",
|
||||
"max-length": 60,
|
||||
"tooltip": true,
|
||||
"tooltip-format": "{title}\n\nclass: {class}",
|
||||
// Titles are written for a window's own title bar, not for a 60-column
|
||||
// slot on a bar, so the worst offenders are trimmed to the part that
|
||||
// identifies the window. ECMAScript regex; the value is a replacement
|
||||
// string where $1 is the first capture.
|
||||
//
|
||||
// The separator is an em dash, and it is written as an escape
|
||||
// for the same reason every glyph in this file is: the template is read
|
||||
// as ASCII and the generator refuses to emit anything that is not, so a
|
||||
// literal em dash pasted here fails the build rather than reaching the
|
||||
// bar. COSMIC's applications really do use an em dash and not a hyphen
|
||||
// -- `j/clients` reports "dingo@fedora:~ <em dash> COSMIC Terminal" -- so
|
||||
// a rule written with a hyphen matches nothing.
|
||||
//
|
||||
// There is deliberately no catch-all for the empty title. waybar's
|
||||
// documentation does not say whether a rule is applied on a full match
|
||||
// or a search, so an empty pattern is either "matches nothing but the
|
||||
// empty title" or "matches every title", and the difference is the
|
||||
// whole bar. The no-window case is handled in rules.css instead, the
|
||||
// same way #mpris already handles having nothing to say.
|
||||
"rewrite": {
|
||||
"(.*) [-\u2014] Vivaldi": "$1",
|
||||
"(.*) [-\u2014] COSMIC Terminal": "$1",
|
||||
"(.*) [-\u2014] COSMIC Text Editor": "$1",
|
||||
"(.*) [-\u2014] COSMIC Files": "$1",
|
||||
"(.*) [-\u2014] Mozilla Firefox": "$1"
|
||||
}
|
||||
},
|
||||
|
||||
// Renders nothing at all when no player is running, which is what we want
|
||||
// from a centre module: it takes no space until there is something to say.
|
||||
// playerctl backs every action here and is installed.
|
||||
"mpris": {
|
||||
"format": "{player_icon} {dynamic}",
|
||||
"format-paused": "{status_icon} <i>{dynamic}</i>",
|
||||
"dynamic-order": ["title", "artist"],
|
||||
"dynamic-separator": " ",
|
||||
"max-length": 40,
|
||||
"interval": 1,
|
||||
"player-icons": { "default": "@@NOTE@@" },
|
||||
"status-icons": {
|
||||
"playing": "@@PLAY@@",
|
||||
"paused": "@@PAUSE@@",
|
||||
"stopped": "@@STOP@@"
|
||||
},
|
||||
"on-click": "playerctl play-pause",
|
||||
"on-click-middle": "playerctl previous",
|
||||
"on-click-right": "playerctl next",
|
||||
"on-scroll-up": "playerctl position 5+",
|
||||
"on-scroll-down": "playerctl position 5-",
|
||||
"tooltip-format": "{title}\nby {artist}\n{position} / {length}\n\nplayer: {player}"
|
||||
},
|
||||
|
||||
"clock": {
|
||||
"format": "{:%a %d %b %H:%M}",
|
||||
"tooltip-format": "<tt><small>{calendar}</small></tt>"
|
||||
},
|
||||
|
||||
// Only appears while something is actually capturing. Nothing is ignored:
|
||||
// an exception list is how a privacy indicator stops being one.
|
||||
"privacy": {
|
||||
"icon-spacing": 6,
|
||||
"transition-duration": 200,
|
||||
"modules": [
|
||||
{ "type": "screenshare", "tooltip": true },
|
||||
{ "type": "audio-in", "tooltip": true }
|
||||
]
|
||||
},
|
||||
|
||||
// First on the right, and silent unless something is wrong. A unit that
|
||||
// failed at boot otherwise stays failed until the day you happen to run
|
||||
// `systemctl --failed` -- there is no other surface for it in a COSMIC
|
||||
// session, which has no equivalent of GNOME's abrt notification.
|
||||
//
|
||||
// Both scopes are watched. The user scope is where a session's own units
|
||||
// fail, which is the half that matters here and the half a system-only
|
||||
// check misses.
|
||||
"systemd-failed-units": {
|
||||
"format": "@@ALERT@@ {nr_failed}",
|
||||
"format-ok": "",
|
||||
"system": true,
|
||||
"user": true,
|
||||
"hide-on-ok": true,
|
||||
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
|
||||
},
|
||||
|
||||
"idle_inhibitor": {
|
||||
"format": "{icon}",
|
||||
"format-icons": {
|
||||
"activated": "@@CAFFEINE_ON@@",
|
||||
"deactivated": "@@CAFFEINE_OFF@@"
|
||||
},
|
||||
"tooltip-format-activated": "Caffeine on -- idling and blanking suppressed",
|
||||
"tooltip-format-deactivated": "Caffeine off -- normal power settings apply"
|
||||
},
|
||||
|
||||
// swaync is this session's notification daemon: it owns
|
||||
// org.freedesktop.Notifications and is D-Bus activated through
|
||||
// swaync.service, so it needs no autostart entry and this module starts it
|
||||
// on first contact if nothing else has.
|
||||
//
|
||||
// It is here because the hyprcosmic profile disables cosmic-panel and
|
||||
// COSMIC's notification applet lives in that panel. Without this there is
|
||||
// no way to reach notification history at all.
|
||||
"custom/swaync": {
|
||||
"format": "{icon}",
|
||||
"format-icons": {
|
||||
"none": "@@BELL@@",
|
||||
"notification": "@@BELL_DOT@@",
|
||||
"dnd-none": "@@BELL_OFF@@",
|
||||
"dnd-notification": "@@BELL_OFF@@",
|
||||
"inhibited-none": "@@BELL@@",
|
||||
"inhibited-notification": "@@BELL_DOT@@",
|
||||
"dnd-inhibited-none": "@@BELL_OFF@@",
|
||||
"dnd-inhibited-notification": "@@BELL_OFF@@"
|
||||
},
|
||||
"return-type": "json",
|
||||
"exec": "swaync-client -swb",
|
||||
"on-click": "swaync-client -t -sw",
|
||||
"on-click-right": "swaync-client -d -sw",
|
||||
"tooltip": true,
|
||||
"escape": true
|
||||
},
|
||||
|
||||
// bluez is running with an unblocked hci0. The click target is COSMIC's own
|
||||
// settings page rather than blueman or overskride, neither of which is
|
||||
// installed here.
|
||||
"bluetooth": {
|
||||
"format": "@@BT_ON@@",
|
||||
"format-disabled": "@@BT_DISABLED@@",
|
||||
"format-off": "@@BT_OFF@@",
|
||||
"format-connected": "@@BT_ON@@ {num_connections}",
|
||||
"tooltip-format": "{controller_alias}\n{num_connections} connected",
|
||||
"tooltip-format-connected": "{controller_alias}\n\n{device_enumerate}",
|
||||
"tooltip-format-enumerate-connected": "{device_alias}",
|
||||
"on-click": "cosmic-settings bluetooth"
|
||||
},
|
||||
|
||||
// The click used to be `pavucontrol`, which is not installed here: a button
|
||||
// that looked live and did nothing. cosmic-settings ships with the desktop,
|
||||
// so it cannot go missing the same way. Scroll and middle-click need no
|
||||
// helper -- waybar changes volume itself and wpctl comes with pipewire.
|
||||
"pulseaudio": {
|
||||
"format": "{icon} {volume}%",
|
||||
"format-muted": "@@VOL_MUTE@@",
|
||||
"format-bluetooth": "{icon} {volume}%",
|
||||
"format-bluetooth-muted": "@@VOL_MUTE@@",
|
||||
"format-icons": {
|
||||
"headphone": "@@VOL_HEADPHONE@@",
|
||||
"headset": "@@VOL_HEADSET@@",
|
||||
"hands-free": "@@VOL_HANDSFREE@@",
|
||||
"phone": "@@VOL_PHONE@@",
|
||||
"car": "@@VOL_CAR@@",
|
||||
"default": ["@@VOL_LO@@", "@@VOL_MID@@", "@@VOL_HI@@"]
|
||||
},
|
||||
"scroll-step": 5,
|
||||
"tooltip-format": "{desc}\n{icon} {volume}%",
|
||||
"on-click": "cosmic-settings sound",
|
||||
"on-click-middle": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
|
||||
},
|
||||
|
||||
"network": {
|
||||
"format-wifi": "@@WIFI@@ {signalStrength}%",
|
||||
"format-ethernet": "@@ETH@@ wired",
|
||||
"format-linked": "@@ETH@@ {ifname}",
|
||||
"format-disconnected": "@@NET_OFF@@ offline",
|
||||
"tooltip-format": "{ifname}: {ipaddr}",
|
||||
"tooltip-format-wifi": "{essid} ({signalStrength}%)\n{ifname}: {ipaddr}",
|
||||
"on-click": "cosmic-settings network"
|
||||
},
|
||||
|
||||
// Root only. /home is on the same filesystem here, and a second entry for
|
||||
// it would be the same number twice.
|
||||
//
|
||||
// No warning threshold is set: waybar's disk module has no `states`, so the
|
||||
// colour comes from rules.css, which cannot see the percentage. The number
|
||||
// is the warning.
|
||||
"disk": {
|
||||
"path": "/",
|
||||
"format": "@@DISK@@ {percentage_used}%",
|
||||
"interval": 60,
|
||||
"tooltip-format": "{used} used of {total} on {path}, {free} free"
|
||||
},
|
||||
|
||||
// Tctl from k10temp, not thermal_zone0. The two read within a degree of
|
||||
// each other here, but the hwmon path names the CPU sensor explicitly.
|
||||
//
|
||||
// It is given as the PCI device's hwmon directory rather than
|
||||
// /sys/class/hwmon/hwmon6 because hwmonN numbering is assigned in probe
|
||||
// order and is not stable across boots. The PCI address is.
|
||||
"temperature": {
|
||||
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:18.3/hwmon",
|
||||
"input-filename": "temp1_input",
|
||||
"format": "@@TEMP@@ {temperatureC} C",
|
||||
"critical-threshold": 90,
|
||||
"interval": 5,
|
||||
"tooltip-format": "CPU package: {temperatureC} C"
|
||||
},
|
||||
|
||||
// A second instance of the same module. `temperature#gpu` is waybar's alias
|
||||
// syntax: everything before the # selects the module, everything after is
|
||||
// just a name, and it becomes #temperature.gpu in CSS.
|
||||
//
|
||||
// The dGPU's own sensor, not the CPU's. On this machine the two diverge by
|
||||
// 30 degrees under load, so one reading cannot stand for both.
|
||||
//
|
||||
// temp1_label reads "edge" -- the die edge, which is the sensor amdgpu
|
||||
// exposes on this card. There is no junction or memory sensor here to
|
||||
// prefer over it.
|
||||
//
|
||||
// hwmon-path-abs is safe for this one: the PCI device has exactly one
|
||||
// hwmonN inside it, unlike the asus platform device that custom/fan below
|
||||
// has to work around.
|
||||
"temperature#gpu": {
|
||||
"hwmon-path-abs": "/sys/devices/pci0000:00/0000:00:08.1/0000:05:00.0/hwmon",
|
||||
"input-filename": "temp1_input",
|
||||
"format": "@@GPU@@ {temperatureC} C",
|
||||
"critical-threshold": 95,
|
||||
"interval": 5,
|
||||
"tooltip-format": "GPU edge: {temperatureC} C"
|
||||
},
|
||||
|
||||
// Speed of the fastest fan, with every fan in the tooltip. See the script
|
||||
// for why this is not a temperature module with a different divisor and not
|
||||
// an hwmon-path-abs: both were tried and neither can name this sensor.
|
||||
//
|
||||
// Emits nothing on a machine with no readable fan, and waybar draws an
|
||||
// empty custom module as nothing, so this costs no space on hardware that
|
||||
// has none.
|
||||
"custom/fan": {
|
||||
"exec": "hyprcosmic-fan",
|
||||
"return-type": "json",
|
||||
"format": "@@FAN@@ {} rpm",
|
||||
"interval": 5
|
||||
},
|
||||
|
||||
"cpu": {
|
||||
"format": "@@CPU@@ {usage}%",
|
||||
"interval": 5
|
||||
},
|
||||
|
||||
"memory": {
|
||||
"format": "@@MEM@@ {percentage}%",
|
||||
"interval": 5,
|
||||
"tooltip-format": "{used:0.1f}G of {total:0.1f}G"
|
||||
},
|
||||
|
||||
// Backed by tuned-ppd, which owns net.hadess.PowerProfiles on this system.
|
||||
"power-profiles-daemon": {
|
||||
"format": "{icon}",
|
||||
"format-icons": {
|
||||
"default": "@@PROFILE@@",
|
||||
"performance": "@@PERF@@",
|
||||
"balanced": "@@BALANCED@@",
|
||||
"power-saver": "@@SAVER@@"
|
||||
},
|
||||
"tooltip-format": "power profile: {profile}",
|
||||
"tooltip": true
|
||||
},
|
||||
|
||||
"battery": {
|
||||
"states": { "warning": 30, "critical": 15 },
|
||||
"format": "{icon} {capacity}%",
|
||||
"format-charging": "@@PLUG@@ {capacity}%",
|
||||
"format-plugged": "@@PLUG@@ {capacity}%",
|
||||
"format-icons": [
|
||||
"@@BAT_00@@",
|
||||
"@@BAT_10@@",
|
||||
"@@BAT_20@@",
|
||||
"@@BAT_30@@",
|
||||
"@@BAT_40@@",
|
||||
"@@BAT_50@@",
|
||||
"@@BAT_60@@",
|
||||
"@@BAT_70@@",
|
||||
"@@BAT_80@@",
|
||||
"@@BAT_90@@",
|
||||
"@@BAT_100@@"
|
||||
],
|
||||
"tooltip-format": "{timeTo}\n{power:0.1f}W",
|
||||
"on-click": "cosmic-settings power"
|
||||
},
|
||||
|
||||
"tray": { "icon-size": 18, "spacing": 8 },
|
||||
|
||||
// Last on the bar, because it is the one button here that can end the
|
||||
// session or the uptime and the far corner is the hardest place to hit by
|
||||
// accident.
|
||||
//
|
||||
// It is here for the same reason "custom/swaync" is: the hyprcosmic profile
|
||||
// disables cosmic-panel, and COSMIC's power applet lives in that panel.
|
||||
// Without this there is no logout, reboot or shutdown anywhere in the
|
||||
// session -- the only way out was `systemctl reboot` from a terminal.
|
||||
//
|
||||
// The click runs the same script as the $mainMod SHIFT E binding rather
|
||||
// than calling systemctl here, so the confirmation step cannot be bypassed
|
||||
// by using the mouse.
|
||||
"custom/power": {
|
||||
"format": "@@POWER@@",
|
||||
"tooltip": true,
|
||||
"tooltip-format": "Lock, suspend, log out, reboot or shut down",
|
||||
"on-click": "hyprcosmic-powermenu"
|
||||
}
|
||||
}
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate waybar config.jsonc with \\uXXXX escapes from verified codepoints.
|
||||
|
||||
Every codepoint here was confirmed present in JetBrainsMono Nerd Font with
|
||||
`fc-list :charset=<cp>`. The template is pure ASCII and uses @TOKEN@
|
||||
placeholders so that no PUA character is ever typed by hand.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
# name -> codepoint. Verified present in the installed Nerd Font.
|
||||
ICONS = {
|
||||
"VOL_LO": 0xF026,
|
||||
"VOL_MID": 0xF027,
|
||||
"VOL_HI": 0xF028,
|
||||
"VOL_HEADPHONE": 0xF025,
|
||||
"VOL_HEADSET": 0xF025,
|
||||
"VOL_HANDSFREE": 0xF025,
|
||||
"VOL_PHONE": 0xF095,
|
||||
"VOL_CAR": 0xF1B9,
|
||||
"VOL_MUTE": 0xF075F,
|
||||
"BT_ON": 0xF293,
|
||||
"BT_DISABLED": 0xF294,
|
||||
"BT_OFF": 0xF00B2,
|
||||
"CPU": 0xF035B,
|
||||
"MEM": 0xF0EE0,
|
||||
"TEMP": 0xF2CA,
|
||||
"WIFI": 0xF1EB,
|
||||
"ETH": 0xF0200,
|
||||
"NET_OFF": 0xF092F,
|
||||
"PLUG": 0xF1E6,
|
||||
"CAFFEINE_ON": 0xF0176,
|
||||
"CAFFEINE_OFF": 0xF06CA,
|
||||
"PLAY": 0xF04B,
|
||||
"PAUSE": 0xF04C,
|
||||
"STOP": 0xF04D,
|
||||
"NOTE": 0xF001,
|
||||
"BELL": 0xF0F3,
|
||||
"BELL_DOT": 0xF009A,
|
||||
"BELL_OFF": 0xF1F6,
|
||||
"PERF": 0xF135,
|
||||
"SAVER": 0xF06C,
|
||||
"BALANCED": 0xF24E,
|
||||
"PROFILE": 0xF0E7,
|
||||
"POWER": 0xF011,
|
||||
"BAT_00": 0xF008E,
|
||||
"BAT_10": 0xF007A,
|
||||
"BAT_20": 0xF007B,
|
||||
"BAT_30": 0xF007C,
|
||||
"BAT_40": 0xF007D,
|
||||
"BAT_50": 0xF007E,
|
||||
"BAT_60": 0xF007F,
|
||||
"BAT_70": 0xF0080,
|
||||
"BAT_80": 0xF0081,
|
||||
"BAT_90": 0xF0082,
|
||||
"BAT_100": 0xF0079,
|
||||
"WINDOW": 0xF2D0,
|
||||
"ALERT": 0xF0026,
|
||||
"DISK": 0xF02CA,
|
||||
"GPU": 0xF08AE,
|
||||
"FAN": 0xF0210,
|
||||
"KEYBOARD": 0xF030C,
|
||||
}
|
||||
|
||||
|
||||
def escape(cp: int) -> str:
|
||||
"""JSON escape for one codepoint, surrogate pair when above the BMP."""
|
||||
return json.dumps(chr(cp), ensure_ascii=True)[1:-1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
template_path, out_path = sys.argv[1], sys.argv[2]
|
||||
text = open(template_path, encoding="ascii").read()
|
||||
|
||||
unknown = {t for t in re.findall(r"@@([A-Z0-9_]+)@@", text) if t not in ICONS}
|
||||
if unknown:
|
||||
print("unknown tokens:", sorted(unknown), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
used = set()
|
||||
|
||||
def sub(m):
|
||||
used.add(m.group(1))
|
||||
return escape(ICONS[m.group(1)])
|
||||
|
||||
out = re.sub(r"@@([A-Z0-9_]+)@@", sub, text)
|
||||
|
||||
if not out.isascii():
|
||||
print("generated file is not pure ASCII", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
open(out_path, "w", encoding="ascii").write(out)
|
||||
|
||||
unused = sorted(set(ICONS) - used)
|
||||
print(f"wrote {out_path}: {len(out.splitlines())} lines, {len(used)} icons used")
|
||||
if unused:
|
||||
print("unused icon names:", unused)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Default colours for the HyprCosmic bar.
|
||||
*
|
||||
* Two name sets are defined here, and both matter:
|
||||
*
|
||||
* - `main-bg`, `wb-act-bg` and friends are HyDE's names. A HyDE theme's
|
||||
* waybar.theme is nothing but a list of these, so defining them here means
|
||||
* a theme file can override them by being imported after this one.
|
||||
* - `bar-bg`, `accent` and friends are the names rules.css actually uses.
|
||||
*
|
||||
* bridge-hyde.css maps the first set onto the second. Defining HyDE's names
|
||||
* here as well is what makes a missing theme file harmless: the bridge always
|
||||
* has something to resolve against, so an unthemed bar renders with these
|
||||
* values instead of failing to parse.
|
||||
*
|
||||
* Values are Tokyo Night, matching cosmic-conf's own defaults.
|
||||
*/
|
||||
|
||||
/* HyDE's names. Overridden by ~/.config/waybar/theme.css when a theme is in. */
|
||||
@define-color main-bg #1a1b26;
|
||||
@define-color main-fg #c0caf5;
|
||||
@define-color wb-act-bg #7aa2f7;
|
||||
@define-color wb-act-fg #1a1b26;
|
||||
@define-color wb-hvr-bg #7aa2f7;
|
||||
@define-color wb-hvr-fg #565f89;
|
||||
|
||||
/* Names with no HyDE equivalent, so never themed and always these values. */
|
||||
@define-color warning #e0af68;
|
||||
@define-color critical #f7768e;
|
||||
|
||||
/* The bar's own background. HyDE themes define this one under the same name
|
||||
* we use, and typically as a low-alpha rgba() so the compositor's blur shows
|
||||
* through. */
|
||||
@define-color bar-bg rgba(26, 27, 38, 0.85);
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Geometry and layout for the HyprCosmic bar.
|
||||
*
|
||||
* No colour literals: every colour here is a name defined by palette.css and
|
||||
* possibly re-pointed by bridge-hyde.css. That separation is what lets a theme
|
||||
* change the palette without touching a single rule.
|
||||
*
|
||||
* The look is HyDE's: a strip carrying rounded pills rather than a flat row of
|
||||
* text. HyDE gets its pills to float by making the bar itself transparent and
|
||||
* letting the compositor blur the desktop behind them; cosmic-comp has no
|
||||
* rule-driven blur, so the bar keeps a background here and the pills lift off
|
||||
* it instead. See bridge-hyde.css for the two colours that implements.
|
||||
*/
|
||||
|
||||
* {
|
||||
/* JetBrainsMono Nerd Font first, and it has to be first.
|
||||
*
|
||||
* This previously named "FontAwesome 6 Free", which is not even the
|
||||
* installed family's real name ("Font Awesome 6 Free") -- fontconfig's
|
||||
* fuzzy matching resolved it anyway, to the Regular face, which carries
|
||||
* only a small subset of the icon set. Every glyph outside that subset
|
||||
* then came from whatever fallback fontconfig picked per character, so the
|
||||
* bar's icons were only accidentally consistent.
|
||||
*
|
||||
* The Nerd Font carries the whole icon set and the text, so naming it
|
||||
* first makes one font answer for both. Every codepoint in config.jsonc
|
||||
* was checked against this family specifically. */
|
||||
font-family: "JetBrainsMono Nerd Font", "Noto Sans", sans-serif;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
window#waybar {
|
||||
background: @bar-bg;
|
||||
color: @bar-fg;
|
||||
}
|
||||
|
||||
/* Workspaces: pills, with the active one filled rather than underlined. */
|
||||
#workspaces {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
#workspaces button {
|
||||
padding: 0 10px;
|
||||
margin: 4px 2px;
|
||||
border-radius: 10px;
|
||||
color: @muted;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#workspaces button.active {
|
||||
color: @wb-act-fg;
|
||||
background: @accent;
|
||||
}
|
||||
|
||||
#workspaces button:hover {
|
||||
color: @wb-hvr-fg;
|
||||
background: @wb-hvr-bg;
|
||||
}
|
||||
|
||||
#taskbar button {
|
||||
padding: 0 6px;
|
||||
margin: 4px 1px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#taskbar button.active {
|
||||
background: @module-bg;
|
||||
}
|
||||
|
||||
#taskbar button:hover {
|
||||
background: @wb-hvr-bg;
|
||||
}
|
||||
|
||||
/* Every status module gets the same pill. Listed one per line because waybar
|
||||
* takes the CSS id from the module name with `/` turned into `-`, so these are
|
||||
* not guessable from the config and are worth being able to read down. */
|
||||
#custom-keybinds,
|
||||
#window,
|
||||
#mpris,
|
||||
#clock,
|
||||
#privacy,
|
||||
#systemd-failed-units,
|
||||
#idle_inhibitor,
|
||||
#custom-swaync,
|
||||
#bluetooth,
|
||||
#pulseaudio,
|
||||
#network,
|
||||
#disk,
|
||||
#temperature,
|
||||
#custom-fan,
|
||||
#cpu,
|
||||
#memory,
|
||||
#power-profiles-daemon,
|
||||
#battery,
|
||||
#tray,
|
||||
#custom-power {
|
||||
padding: 0 10px;
|
||||
margin: 4px 2px;
|
||||
border-radius: 10px;
|
||||
background: @module-bg;
|
||||
}
|
||||
|
||||
/* The only module here that is a button rather than a readout, so it is the
|
||||
* only one that gains anything from a hover state. Colour rather than
|
||||
* background, to match how the status colours below signal without moving
|
||||
* anything. */
|
||||
#custom-power:hover {
|
||||
color: @critical;
|
||||
}
|
||||
|
||||
/* The other button on the bar. Accent rather than critical, because this one is
|
||||
* safe to press. */
|
||||
#custom-keybinds:hover {
|
||||
color: @accent;
|
||||
}
|
||||
|
||||
#clock {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* mpris renders nothing when no player is running. Without this it would still
|
||||
* draw an empty pill, so the padding goes away with the content. */
|
||||
#mpris {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#mpris.playing,
|
||||
#mpris.paused {
|
||||
padding: 0 10px;
|
||||
background: @module-bg;
|
||||
}
|
||||
|
||||
/* Same reasoning: privacy is only present while something is capturing. */
|
||||
#privacy {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#privacy-item {
|
||||
padding: 0 8px;
|
||||
margin: 4px 2px;
|
||||
border-radius: 10px;
|
||||
background: @module-bg;
|
||||
color: @warning;
|
||||
}
|
||||
|
||||
/* Same reasoning as mpris, but the class is not on the widget. waybar puts
|
||||
* `empty` on the whole bar rather than on #window, so the selector has to
|
||||
* descend into it -- waybar-hyprland-window(5) gives it as exactly this:
|
||||
*
|
||||
* window#waybar.empty #window When no windows are in the workspace
|
||||
*
|
||||
* The first `window` is the GTK window, the second is our module; they are
|
||||
* unrelated names that collide, which is why this reads so oddly. */
|
||||
window#waybar.empty #window {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* States. These recolour the text inside the pill rather than the pill, so a
|
||||
* warning cannot make a module unreadable against its own background. */
|
||||
#battery.warning { color: @warning; }
|
||||
#battery.critical { color: @critical; }
|
||||
#temperature.critical { color: @critical; }
|
||||
#network.disconnected { color: @critical; }
|
||||
|
||||
/* No state class needed: hide-on-ok removes the module entirely at zero, so it
|
||||
* is on screen only when there is something to report and can be coloured
|
||||
* unconditionally. */
|
||||
#systemd-failed-units { color: @critical; }
|
||||
|
||||
/* Set by hyprcosmic-fan: `high` above 4000 rpm, `idle` when every fan has
|
||||
* stopped. `normal` is left alone. */
|
||||
#custom-fan.high { color: @warning; }
|
||||
#custom-fan.idle { color: @muted; }
|
||||
#pulseaudio.muted { color: @muted; }
|
||||
#idle_inhibitor.activated { color: @accent; }
|
||||
#custom-swaync.notification { color: @accent; }
|
||||
#custom-swaync.dnd-none,
|
||||
#custom-swaync.dnd-notification { color: @muted; }
|
||||
|
||||
/* No `#tray:empty` rule to hide the pill when nothing is in the tray: GTK's
|
||||
* CSS has no :empty pseudo-class and rejects the whole stylesheet for it,
|
||||
* which is fatal rather than cosmetic. waybar exits and the bar never appears.
|
||||
*/
|
||||
@@ -0,0 +1,32 @@
|
||||
/* Waybar styling for a HyprCosmic session.
|
||||
*
|
||||
* This file is only an import list, because the order is the whole design:
|
||||
*
|
||||
* 1. palette.css default colours, under both our names and HyDE's
|
||||
* 2. theme.css the installed HyDE theme's waybar.theme, if any
|
||||
* 3. bridge-hyde.css maps HyDE's colour names onto the ones the rules use
|
||||
* 4. rules.css geometry and layout; no colour literals at all
|
||||
*
|
||||
* Every entry is an @import, so they are read strictly in sequence and a later
|
||||
* definition of a colour wins over an earlier one. That is what lets step 2
|
||||
* recolour the bar without step 4 knowing a theme exists.
|
||||
*
|
||||
* Step 2 is a copy of the installed theme's waybar.theme, kept as a sibling of
|
||||
* this file rather than read from HyDE's own ~/.config/waybar/theme.css.
|
||||
*
|
||||
* That copy exists because a missing @import is fatal in GTK, not a warning:
|
||||
* pointing at HyDE's path directly means the whole stylesheet fails to load on
|
||||
* any machine where no theme has been imported. A sibling file we create at
|
||||
* install time is always present -- empty when there is no theme, in which
|
||||
* case the defaults from step 1 stand. palette.css defines HyDE's names too,
|
||||
* so the bridge in step 3 resolves either way.
|
||||
*
|
||||
* Consequently this file belongs at ~/.config/hyprcosmic/waybar/style.css, not
|
||||
* under /usr/share: a relative @import resolves against the importing file,
|
||||
* and theme.css is per-user.
|
||||
*/
|
||||
|
||||
@import url("file:///usr/share/hyprcosmic/waybar/palette.css");
|
||||
@import url("theme.css");
|
||||
@import url("file:///usr/share/hyprcosmic/waybar/bridge-hyde.css");
|
||||
@import url("file:///usr/share/hyprcosmic/waybar/rules.css");
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Step 2 of the import chain in style.css: the installed HyDE theme's
|
||||
* waybar.theme.
|
||||
*
|
||||
* This copy is deliberately empty of rules. It is not a stub waiting to be
|
||||
* written -- an empty file is the correct content when no theme has been
|
||||
* imported, and it is what makes the other three steps work:
|
||||
*
|
||||
* - A missing @import is fatal in GTK, so style.css cannot name a file that
|
||||
* might not exist. This one always exists, from the moment the session
|
||||
* first starts.
|
||||
* - Every colour the rules consume already has a default from palette.css
|
||||
* and bridge-hyde.css, so a file that defines nothing leaves a complete
|
||||
* stylesheet rather than an undefined name.
|
||||
*
|
||||
* `hyprcosmic-conf import-theme` overwrites this with the waybar.theme of the
|
||||
* theme being imported. Anything written here by hand is lost at that point,
|
||||
* which is why the per-user override belongs in style.css instead.
|
||||
*/
|
||||
+1
-1
Submodule cosmic-applets updated: 2b88f35991...ec8ffdc85d
+1
-1
Submodule cosmic-applibrary updated: ebc8b462b2...172acdae07
+1
-1
Submodule cosmic-bg updated: b6adf25075...76e89e6aff
+1
-1
Submodule cosmic-comp updated: e0f78da0ae...a73734edb0
@@ -0,0 +1,4 @@
|
||||
# Single-core builds: this crate is developed on a machine where parallel
|
||||
# rustc jobs are not wanted.
|
||||
[build]
|
||||
jobs = 1
|
||||
Generated
+526
@@ -0,0 +1,526 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cosmic-conf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"notify",
|
||||
"regex",
|
||||
"ron",
|
||||
"serde",
|
||||
"tar",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio",
|
||||
"notify-types",
|
||||
"walkdir",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-types"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"typeid",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||
dependencies = [
|
||||
"same-file",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "cosmic-conf"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-only"
|
||||
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
|
||||
|
||||
[dependencies]
|
||||
flate2 = "1.1.9"
|
||||
notify = "8.2.0"
|
||||
# Only to reject a bad `windowrule` expression at apply time rather than
|
||||
# letting it fail silently in the compositor. Pinned to the same major the
|
||||
# compositor matches with, so what compiles here compiles there.
|
||||
regex = "1"
|
||||
ron = "0.12"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
tar = "0.4.46"
|
||||
|
||||
[features]
|
||||
# `emit` links cosmic-config and the component crates. Off by default so the
|
||||
# pure units (parser, schema, resolve) build and test without the libcosmic
|
||||
# dependency graph.
|
||||
default = []
|
||||
emit = []
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
//! Hyprland `bind` lines -> COSMIC shortcut bindings.
|
||||
//!
|
||||
//! `bind = SUPER, D, exec, rofi -show drun` is the single most recognisable
|
||||
//! line in a hyprland.conf, so it is the one piece of the idiom that has to
|
||||
//! feel native rather than translated.
|
||||
//!
|
||||
//! The target is the `custom` key of `com.system76.CosmicSettings.Shortcuts`,
|
||||
//! which the compositor merges over `defaults`, letting a bind here override a
|
||||
//! stock COSMIC shortcut without touching the system file
|
||||
//! (cosmic-settings-daemon `config/src/shortcuts/mod.rs`: `shortcuts()` reads
|
||||
//! `defaults`, then extends with `custom`).
|
||||
//!
|
||||
//! Actions are rendered as RON text rather than modelled as an enum. COSMIC's
|
||||
//! `Action` has forty-odd variants and this crate deliberately does not link
|
||||
//! the cosmic crates; mirroring the enum would mean re-copying it every time
|
||||
//! upstream adds a variant, and the mapping table below only ever needs a few.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::parser::Span;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Bind {
|
||||
/// COSMIC modifier names, deduplicated and in COSMIC's own order.
|
||||
pub mods: Vec<&'static str>,
|
||||
/// xkb keysym name. `None` is a modifier-only binding, which COSMIC
|
||||
/// supports and its defaults use for the launcher on bare Super.
|
||||
pub key: Option<String>,
|
||||
/// Pre-rendered RON, e.g. `Spawn("rofi -show drun")` or `Focus(Left)`.
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BindError {
|
||||
pub message: String,
|
||||
pub help: Option<String>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> BindError {
|
||||
BindError {
|
||||
message: message.into(),
|
||||
help: help.map(str::to_string),
|
||||
span,
|
||||
}
|
||||
}
|
||||
|
||||
/// Modifier spellings Hyprland accepts, longest first so that the greedy scan
|
||||
/// below consumes `SUPERSHIFT` correctly rather than stopping at a prefix.
|
||||
const MODIFIERS: &[(&str, &str)] = &[
|
||||
("SUPERKEY", "Super"),
|
||||
("CONTROL", "Ctrl"),
|
||||
("SHIFT", "Shift"),
|
||||
("SUPER", "Super"),
|
||||
("LOGO", "Super"),
|
||||
("MOD4", "Super"),
|
||||
("MOD1", "Alt"),
|
||||
("CTRL", "Ctrl"),
|
||||
("META", "Super"),
|
||||
("ALT", "Alt"),
|
||||
("WIN", "Super"),
|
||||
];
|
||||
|
||||
/// COSMIC writes modifiers in this order in its own defaults; matching it keeps
|
||||
/// generated files diffable against hand-written ones.
|
||||
const MODIFIER_ORDER: &[&str] = &["Super", "Ctrl", "Alt", "Shift"];
|
||||
|
||||
/// Hyprland allows `SUPER SHIFT`, `SUPER+SHIFT` and bare `SUPERSHIFT`, so
|
||||
/// separators are stripped and the remainder is consumed greedily.
|
||||
fn parse_modifiers(raw: &str, span: Span) -> Result<Vec<&'static str>, BindError> {
|
||||
let mut rest: String = raw
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '+' && *c != '_')
|
||||
.collect::<String>()
|
||||
.to_ascii_uppercase();
|
||||
|
||||
let mut found: Vec<&'static str> = Vec::new();
|
||||
'outer: while !rest.is_empty() {
|
||||
for (spelling, cosmic) in MODIFIERS {
|
||||
if let Some(tail) = rest.strip_prefix(spelling) {
|
||||
if !found.contains(cosmic) {
|
||||
found.push(cosmic);
|
||||
}
|
||||
rest = tail.to_string();
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
return Err(err(
|
||||
span,
|
||||
format!("unknown modifier `{rest}`"),
|
||||
Some("known modifiers: SUPER, CTRL, ALT, SHIFT"),
|
||||
));
|
||||
}
|
||||
|
||||
found.sort_by_key(|m| {
|
||||
MODIFIER_ORDER
|
||||
.iter()
|
||||
.position(|o| o == m)
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Named keys whose xkb spelling differs from what a Hyprland user types.
|
||||
///
|
||||
/// Anything absent falls through unchanged, so exact keysyms such as
|
||||
/// `XF86AudioRaiseVolume` keep working without needing an entry here.
|
||||
const KEY_NAMES: &[(&str, &str)] = &[
|
||||
("return", "Return"),
|
||||
("enter", "Return"),
|
||||
("escape", "Escape"),
|
||||
("esc", "Escape"),
|
||||
("tab", "Tab"),
|
||||
("backspace", "BackSpace"),
|
||||
("delete", "Delete"),
|
||||
("insert", "Insert"),
|
||||
("home", "Home"),
|
||||
("end", "End"),
|
||||
("pageup", "Prior"),
|
||||
("pagedown", "Next"),
|
||||
("left", "Left"),
|
||||
("right", "Right"),
|
||||
("up", "Up"),
|
||||
("down", "Down"),
|
||||
("print", "Print"),
|
||||
];
|
||||
|
||||
/// COSMIC's defaults spell letters lowercase (`key: "q"`) and punctuation by
|
||||
/// keysym name (`key: "slash"`), so normalise toward that.
|
||||
fn normalize_key(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
|
||||
if let Some((_, name)) = KEY_NAMES.iter().find(|(k, _)| *k == lower) {
|
||||
return Some((*name).to_string());
|
||||
}
|
||||
// Function keys are uppercase-F in xkb.
|
||||
if let Some(n) = lower.strip_prefix('f') {
|
||||
if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!("F{n}"));
|
||||
}
|
||||
}
|
||||
if trimmed.len() == 1 && trimmed.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Some(lower);
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn ron_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn direction(arg: &str, span: Span, dispatcher: &str) -> Result<&'static str, BindError> {
|
||||
Ok(match arg.trim().to_ascii_lowercase().as_str() {
|
||||
"l" | "left" => "Left",
|
||||
"r" | "right" => "Right",
|
||||
"u" | "up" => "Up",
|
||||
"d" | "down" => "Down",
|
||||
other => {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("`{dispatcher}` needs a direction, got `{other}`"),
|
||||
Some("use l, r, u or d"),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_index(arg: &str, span: Span, dispatcher: &str) -> Result<u8, BindError> {
|
||||
arg.trim().parse::<u8>().map_err(|_| {
|
||||
err(
|
||||
span,
|
||||
format!(
|
||||
"`{dispatcher}` needs a workspace number, got `{}`",
|
||||
arg.trim()
|
||||
),
|
||||
Some("COSMIC addresses workspaces 1-255 by index"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Translate a Hyprland dispatcher and its argument into RON for COSMIC's
|
||||
/// `Action`.
|
||||
///
|
||||
/// Only dispatchers with a genuine COSMIC equivalent are mapped. A dispatcher
|
||||
/// that merely looks similar is rejected instead of approximated, because a
|
||||
/// keybinding that silently does the wrong thing is worse than one that fails
|
||||
/// to compile.
|
||||
fn action(dispatcher: &str, arg: &str, span: Span) -> Result<String, BindError> {
|
||||
let d = dispatcher.trim().to_ascii_lowercase();
|
||||
Ok(match d.as_str() {
|
||||
"exec" => {
|
||||
let cmd = arg.trim();
|
||||
if cmd.is_empty() {
|
||||
return Err(err(span, "`exec` needs a command", None));
|
||||
}
|
||||
// cosmic-comp runs this through `/bin/sh -c`
|
||||
// (`src/input/actions.rs`: `spawn_command`), so a full command line
|
||||
// with arguments and quoting behaves as written.
|
||||
format!("Spawn({})", ron_string(cmd))
|
||||
}
|
||||
"killactive" => "Close".into(),
|
||||
"fullscreen" => "Fullscreen".into(),
|
||||
"togglefloating" => "ToggleWindowFloating".into(),
|
||||
"togglesplit" => "ToggleOrientation".into(),
|
||||
"togglegroup" => "ToggleStacking".into(),
|
||||
"pin" => "ToggleSticky".into(),
|
||||
"exit" => "System(LogOut)".into(),
|
||||
"movefocus" => format!("Focus({})", direction(arg, span, &d)?),
|
||||
"movewindow" => format!("Move({})", direction(arg, span, &d)?),
|
||||
"workspace" => format!("Workspace({})", workspace_index(arg, span, &d)?),
|
||||
"movetoworkspace" => format!("MoveToWorkspace({})", workspace_index(arg, span, &d)?),
|
||||
"movetoworkspacesilent" => format!("SendToWorkspace({})", workspace_index(arg, span, &d)?),
|
||||
"focusmonitor" => format!("SwitchOutput({})", direction(arg, span, &d)?),
|
||||
"movewindowtomonitor" => format!("MoveToOutput({})", direction(arg, span, &d)?),
|
||||
|
||||
// Present in Hyprland, absent from COSMIC. Named explicitly so the
|
||||
// error says why rather than "unknown".
|
||||
"pseudo" | "forcerendererreload" | "submap" | "toggleopaque" | "centerwindow"
|
||||
| "splitratio" | "cyclenext" | "swapnext" => {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("`{d}` has no COSMIC equivalent"),
|
||||
Some("remove the bind, or use `exec` to run a program instead"),
|
||||
))
|
||||
}
|
||||
other => {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("unknown dispatcher `{other}`"),
|
||||
Some("supported: exec, killactive, fullscreen, togglefloating, togglesplit, movefocus, movewindow, workspace, movetoworkspace, exit"),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the value of one `bind = ...` line.
|
||||
///
|
||||
/// Shape is `MODS, KEY, dispatcher, args`, with args keeping any further
|
||||
/// commas, since `exec` commands routinely contain them.
|
||||
pub fn parse_bind(value: &str, span: Span) -> Result<Bind, BindError> {
|
||||
let parts: Vec<&str> = value.splitn(4, ',').collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(err(
|
||||
span,
|
||||
"a bind needs at least MODS, KEY and a dispatcher",
|
||||
Some("for example: bind = SUPER, D, exec, rofi -show drun"),
|
||||
));
|
||||
}
|
||||
|
||||
let mods = parse_modifiers(parts[0], span)?;
|
||||
let key = normalize_key(parts[1]);
|
||||
if mods.is_empty() && key.is_none() {
|
||||
return Err(err(span, "a bind needs a modifier or a key", None));
|
||||
}
|
||||
|
||||
let arg = parts.get(3).copied().unwrap_or("");
|
||||
let action = action(parts[2], arg, span)?;
|
||||
|
||||
Ok(Bind { mods, key, action })
|
||||
}
|
||||
|
||||
/// Render the collected binds as the RON map COSMIC stores in `custom`.
|
||||
pub fn render(binds: &[Bind]) -> String {
|
||||
let mut out = String::from("{\n");
|
||||
for b in binds {
|
||||
let mods = b
|
||||
.mods
|
||||
.iter()
|
||||
.map(|m| m.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
match &b.key {
|
||||
// `key` is `skip_serializing_if = "Option::is_none"` on COSMIC's
|
||||
// `Binding`, and its own defaults omit it for `(modifiers: [Super])`.
|
||||
Some(k) => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" (modifiers: [{mods}], key: {}): {},",
|
||||
ron_string(k),
|
||||
b.action
|
||||
);
|
||||
}
|
||||
None => {
|
||||
let _ = writeln!(out, " (modifiers: [{mods}]): {},", b.action);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const S: Span = Span {
|
||||
line: 1,
|
||||
col: 1,
|
||||
len: 1,
|
||||
};
|
||||
|
||||
fn bind(v: &str) -> Bind {
|
||||
parse_bind(v, S).expect("should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canonical_hyprland_launcher_bind() {
|
||||
let b = bind("SUPER, D, exec, rofi -show drun");
|
||||
assert_eq!(b.mods, vec!["Super"]);
|
||||
assert_eq!(b.key.as_deref(), Some("d"));
|
||||
assert_eq!(b.action, r#"Spawn("rofi -show drun")"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_accept_every_separator_hyprland_does() {
|
||||
for spelling in ["SUPER SHIFT", "SUPER+SHIFT", "SUPERSHIFT", "super shift"] {
|
||||
assert_eq!(
|
||||
bind(&format!("{spelling}, Q, killactive")).mods,
|
||||
vec!["Super", "Shift"],
|
||||
"failed for `{spelling}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_are_ordered_like_cosmics_own_defaults() {
|
||||
assert_eq!(
|
||||
bind("SHIFT ALT CTRL SUPER, Q, killactive").mods,
|
||||
vec!["Super", "Ctrl", "Alt", "Shift"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bind_with_no_key_is_modifier_only() {
|
||||
// COSMIC's defaults bind bare Super to the launcher this way.
|
||||
let b = bind("SUPER, , exec, rofi -show drun");
|
||||
assert_eq!(b.key, None);
|
||||
assert_eq!(
|
||||
render(&[b]),
|
||||
"{\n (modifiers: [Super]): Spawn(\"rofi -show drun\"),\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_normalise_to_xkb_spelling() {
|
||||
assert_eq!(bind("SUPER, Q, killactive").key.as_deref(), Some("q"));
|
||||
assert_eq!(
|
||||
bind("SUPER, Return, killactive").key.as_deref(),
|
||||
Some("Return")
|
||||
);
|
||||
assert_eq!(
|
||||
bind("SUPER, enter, killactive").key.as_deref(),
|
||||
Some("Return")
|
||||
);
|
||||
assert_eq!(bind("SUPER, f5, killactive").key.as_deref(), Some("F5"));
|
||||
assert_eq!(
|
||||
bind("SUPER, slash, killactive").key.as_deref(),
|
||||
Some("slash")
|
||||
);
|
||||
// Unknown names pass through so exact keysyms stay usable.
|
||||
assert_eq!(
|
||||
bind("SUPER, XF86AudioRaiseVolume, killactive")
|
||||
.key
|
||||
.as_deref(),
|
||||
Some("XF86AudioRaiseVolume")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_keeps_commas_in_the_command() {
|
||||
assert_eq!(
|
||||
bind("SUPER, E, exec, sh -c 'echo a, b'").action,
|
||||
r#"Spawn("sh -c 'echo a, b'")"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_in_a_command_are_escaped_not_emitted_raw() {
|
||||
// Otherwise the generated RON would not parse.
|
||||
assert_eq!(
|
||||
bind(r#"SUPER, E, exec, echo "hi""#).action,
|
||||
r#"Spawn("echo \"hi\"")"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatchers_map_to_cosmic_actions() {
|
||||
assert_eq!(bind("SUPER, Q, killactive").action, "Close");
|
||||
assert_eq!(bind("SUPER, F, fullscreen").action, "Fullscreen");
|
||||
assert_eq!(bind("SUPER, left, movefocus, l").action, "Focus(Left)");
|
||||
assert_eq!(
|
||||
bind("SUPER SHIFT, left, movewindow, l").action,
|
||||
"Move(Left)"
|
||||
);
|
||||
assert_eq!(bind("SUPER, 1, workspace, 1").action, "Workspace(1)");
|
||||
assert_eq!(
|
||||
bind("SUPER SHIFT, 1, movetoworkspace, 1").action,
|
||||
"MoveToWorkspace(1)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dispatcher_without_an_equivalent_is_refused_not_approximated() {
|
||||
let e = parse_bind("SUPER, P, pseudo", S).unwrap_err();
|
||||
assert!(e.message.contains("no COSMIC equivalent"), "{}", e.message);
|
||||
assert!(e.help.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_dispatchers_and_modifiers_are_reported() {
|
||||
assert!(parse_bind("SUPER, X, frobnicate", S)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("unknown dispatcher"));
|
||||
assert!(parse_bind("HYPER, X, killactive", S)
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("unknown modifier"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_bind_says_what_shape_is_expected() {
|
||||
let e = parse_bind("SUPER, D", S).unwrap_err();
|
||||
assert!(e.help.unwrap().contains("rofi -show drun"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_matches_the_shape_cosmic_writes_in_its_defaults() {
|
||||
let out = render(&[
|
||||
bind("SUPER, , exec, rofi -show drun"),
|
||||
bind("SUPER, slash, exec, rofi -show drun"),
|
||||
bind("SUPER SHIFT, Q, killactive"),
|
||||
]);
|
||||
assert_eq!(
|
||||
out,
|
||||
concat!(
|
||||
"{\n",
|
||||
" (modifiers: [Super]): Spawn(\"rofi -show drun\"),\n",
|
||||
" (modifiers: [Super], key: \"slash\"): Spawn(\"rofi -show drun\"),\n",
|
||||
" (modifiers: [Super, Shift], key: \"q\"): Close,\n",
|
||||
"}\n"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
//! Resolved writes -> the cosmic-config tree.
|
||||
//!
|
||||
//! Spike 2 established the mechanism (see the spec's verified-findings table):
|
||||
//! cosmic-config is a filesystem key-value store at
|
||||
//! `$XDG_CONFIG_HOME/cosmic/<component>/v<n>/<key>`, each file holding one RON
|
||||
//! literal. `Config::watch` (`cosmic-config/src/lib.rs:377`) is a `notify`
|
||||
//! inotify watch on that directory which derives changed keys from file paths,
|
||||
//! so a plain atomic write is observed exactly like a write from the typed API.
|
||||
//! That is why this module needs `ron` rather than the whole libcosmic graph.
|
||||
//!
|
||||
//! Emission is two-stage on purpose. `plan` reads current state and renders
|
||||
//! every file's new contents without touching disk; `apply` then writes. A
|
||||
//! failure while planning therefore leaves the desktop untouched, preserving
|
||||
//! the transactional guarantee `resolve` starts.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::resolve::{Resolved, TargetKey, Value, Write, WriteKind};
|
||||
|
||||
/// Mirror of `cosmic_theme::CornerRadii` (`cosmic-theme/src/model/corner.rs:5`).
|
||||
///
|
||||
/// Duplicated rather than depended upon so this crate stays free of the
|
||||
/// libcosmic build graph. The field set and defaults are pinned by tests; if
|
||||
/// upstream adds a radius, round-tripping would silently drop it, so
|
||||
/// `deny_unknown_fields` turns that into a loud parse error instead.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CornerRadii {
|
||||
radius_0: [f32; 4],
|
||||
radius_xs: [f32; 4],
|
||||
radius_s: [f32; 4],
|
||||
radius_m: [f32; 4],
|
||||
radius_l: [f32; 4],
|
||||
radius_xl: [f32; 4],
|
||||
}
|
||||
|
||||
impl Default for CornerRadii {
|
||||
/// `corner.rs:20-31`.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
radius_0: [0.0; 4],
|
||||
radius_xs: [4.0; 4],
|
||||
radius_s: [8.0; 4],
|
||||
radius_m: [16.0; 4],
|
||||
radius_l: [32.0; 4],
|
||||
radius_xl: [160.0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CornerRadii {
|
||||
fn field_mut(&mut self, name: &str) -> Option<&mut [f32; 4]> {
|
||||
Some(match name {
|
||||
"radius_0" => &mut self.radius_0,
|
||||
"radius_xs" => &mut self.radius_xs,
|
||||
"radius_s" => &mut self.radius_s,
|
||||
"radius_m" => &mut self.radius_m,
|
||||
"radius_l" => &mut self.radius_l,
|
||||
"radius_xl" => &mut self.radius_xl,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EmitError {
|
||||
Io(io::Error),
|
||||
/// A projected target whose composite shape this emitter cannot rebuild.
|
||||
UnsupportedComposite {
|
||||
key: String,
|
||||
detail: String,
|
||||
},
|
||||
/// An existing file could not be parsed, so read-modify-write is unsafe.
|
||||
Unreadable {
|
||||
path: PathBuf,
|
||||
detail: String,
|
||||
},
|
||||
NoConfigDirectory,
|
||||
}
|
||||
|
||||
impl fmt::Display for EmitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EmitError::Io(e) => write!(f, "io error: {e}"),
|
||||
EmitError::UnsupportedComposite { key, detail } => {
|
||||
write!(f, "cannot write `{key}`: {detail}")
|
||||
}
|
||||
EmitError::Unreadable { path, detail } => {
|
||||
write!(f, "cannot parse existing `{}`: {detail}", path.display())
|
||||
}
|
||||
EmitError::NoConfigDirectory => write!(f, "no config directory available"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EmitError {}
|
||||
|
||||
impl From<io::Error> for EmitError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
EmitError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// One file's worth of pending change. `previous` powers `apply --diff`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Planned {
|
||||
pub path: PathBuf,
|
||||
pub contents: String,
|
||||
pub previous: Option<String>,
|
||||
}
|
||||
|
||||
impl Planned {
|
||||
/// A write that would not change anything on disk.
|
||||
pub fn is_noop(&self) -> bool {
|
||||
self.previous.as_deref() == Some(self.contents.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Emitter {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl Emitter {
|
||||
/// Locate the cosmic-config root the same way cosmic-config does:
|
||||
/// `$XDG_CONFIG_HOME/cosmic`, falling back to `$HOME/.config/cosmic`.
|
||||
pub fn from_env() -> Result<Self, EmitError> {
|
||||
let base = match std::env::var_os("XDG_CONFIG_HOME") {
|
||||
Some(x) if !x.is_empty() => PathBuf::from(x),
|
||||
_ => {
|
||||
let home = std::env::var_os("HOME").ok_or(EmitError::NoConfigDirectory)?;
|
||||
PathBuf::from(home).join(".config")
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
root: base.join("cosmic"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_root(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
fn path_for(&self, target: &TargetKey) -> PathBuf {
|
||||
self.root
|
||||
.join(&target.component)
|
||||
.join(format!("v{}", target.version))
|
||||
.join(&target.key)
|
||||
}
|
||||
|
||||
/// Render every write without touching disk.
|
||||
///
|
||||
/// Returns all errors rather than the first, matching `resolve`'s behaviour
|
||||
/// so a user sees the whole picture in one pass.
|
||||
pub fn plan(&self, resolved: &Resolved) -> Result<Vec<Planned>, Vec<EmitError>> {
|
||||
let mut planned = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for write in &resolved.writes {
|
||||
match self.plan_one(write) {
|
||||
Ok(p) => planned.push(p),
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(planned)
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_one(&self, write: &Write) -> Result<Planned, EmitError> {
|
||||
let path = self.path_for(&write.target);
|
||||
let previous = match fs::read_to_string(&path) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let contents = match &write.kind {
|
||||
WriteKind::Whole(v) => render(v),
|
||||
WriteKind::Projected(fields) => {
|
||||
composite(&write.target, fields, previous.as_deref(), &path)?
|
||||
}
|
||||
// No merge with `previous`: cosmic.conf owns this value outright,
|
||||
// which is the whole point of the one-way model. Anything set in
|
||||
// COSMIC's own settings UI is replaced, not accumulated.
|
||||
WriteKind::Verbatim(s) => s.clone(),
|
||||
};
|
||||
|
||||
Ok(Planned {
|
||||
path,
|
||||
contents,
|
||||
previous,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the plan. Callers should `plan` first so that failures surface
|
||||
/// before any file is touched.
|
||||
pub fn apply(&self, planned: &[Planned]) -> Result<usize, EmitError> {
|
||||
let mut written = 0;
|
||||
for p in planned {
|
||||
if p.is_noop() {
|
||||
continue;
|
||||
}
|
||||
if let Some(dir) = p.path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
atomic_write(&p.path, &p.contents)?;
|
||||
written += 1;
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via temp-file + rename so a reader never observes a partial file.
|
||||
///
|
||||
/// The temp name carries cosmic-config's `.atomicwrite` prefix
|
||||
/// (`cosmic-config/src/lib.rs:408`) so its watcher ignores the intermediate
|
||||
/// file and reacts only to the final rename.
|
||||
fn atomic_write(path: &Path, contents: &str) -> io::Result<()> {
|
||||
let dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let tmp = dir.join(format!(".atomicwrite.{name}"));
|
||||
|
||||
fs::write(&tmp, contents)?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a scalar as the RON literal cosmic-config expects.
|
||||
///
|
||||
/// Exact formatting is not load-bearing — cosmic-config reads with
|
||||
/// `ron::from_str` (`lib.rs:468`) — but the *shape* is: `Option<Srgb>` has three
|
||||
/// components, `Option<Srgba>` four.
|
||||
fn render(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::U32(n) => n.to_string(),
|
||||
Value::F32(n) => render_f32(*n),
|
||||
Value::Str(s) => format!("{s:?}"),
|
||||
Value::Rgb(r, g, b) => format!(
|
||||
"Some((red: {}, green: {}, blue: {}))",
|
||||
render_f32(byte_to_f32(*r)),
|
||||
render_f32(byte_to_f32(*g)),
|
||||
render_f32(byte_to_f32(*b)),
|
||||
),
|
||||
Value::Rgba(r, g, b, a) => format!(
|
||||
"Some((red: {}, green: {}, blue: {}, alpha: {}))",
|
||||
render_f32(byte_to_f32(*r)),
|
||||
render_f32(byte_to_f32(*g)),
|
||||
render_f32(byte_to_f32(*b)),
|
||||
render_f32(byte_to_f32(*a)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn byte_to_f32(b: u8) -> f32 {
|
||||
b as f32 / 255.0
|
||||
}
|
||||
|
||||
/// RON needs floats to look like floats: a bare `10` would deserialize as an
|
||||
/// integer and fail a `f32` field.
|
||||
fn render_f32(n: f32) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
format!("{n:.1}")
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a composite value from folded projections plus whatever is already
|
||||
/// on disk.
|
||||
///
|
||||
/// Only shapes that can be reconstructed correctly are supported. Anything else
|
||||
/// is a hard error rather than a partial write, because silently writing an
|
||||
/// incomplete composite would drop the user's other fields.
|
||||
fn composite(
|
||||
target: &TargetKey,
|
||||
fields: &BTreeMap<Vec<String>, Value>,
|
||||
previous: Option<&str>,
|
||||
path: &Path,
|
||||
) -> Result<String, EmitError> {
|
||||
match target.key.as_str() {
|
||||
// ThemeBuilder.gaps: (u32, u32) ordered (outer, inner) — theme.rs:895,
|
||||
// default (0, 8) — theme.rs:939.
|
||||
"gaps" => {
|
||||
let (mut outer, mut inner) = match previous {
|
||||
Some(text) => {
|
||||
ron::from_str::<(u32, u32)>(text).map_err(|e| EmitError::Unreadable {
|
||||
path: path.to_path_buf(),
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
}
|
||||
None => (0, 8),
|
||||
};
|
||||
|
||||
for (p, v) in fields {
|
||||
let Value::U32(n) = v else {
|
||||
return Err(EmitError::UnsupportedComposite {
|
||||
key: target.key.clone(),
|
||||
detail: format!("expected an integer for index {p:?}"),
|
||||
});
|
||||
};
|
||||
match p.first().map(String::as_str) {
|
||||
Some("0") => outer = *n,
|
||||
Some("1") => inner = *n,
|
||||
other => {
|
||||
return Err(EmitError::UnsupportedComposite {
|
||||
key: target.key.clone(),
|
||||
detail: format!("unknown tuple index {other:?}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(format!("({outer}, {inner})"))
|
||||
}
|
||||
|
||||
// ThemeBuilder.corner_radii: six [f32; 4] fields — corner.rs:5.
|
||||
"corner_radii" => {
|
||||
let mut radii = match previous {
|
||||
Some(text) => {
|
||||
ron::from_str::<CornerRadii>(text).map_err(|e| EmitError::Unreadable {
|
||||
path: path.to_path_buf(),
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
}
|
||||
None => CornerRadii::default(),
|
||||
};
|
||||
|
||||
for (p, v) in fields {
|
||||
let Value::F32(n) = v else {
|
||||
return Err(EmitError::UnsupportedComposite {
|
||||
key: target.key.clone(),
|
||||
detail: format!("expected a number for {p:?}"),
|
||||
});
|
||||
};
|
||||
let Some(name) = p.first() else {
|
||||
return Err(EmitError::UnsupportedComposite {
|
||||
key: target.key.clone(),
|
||||
detail: "missing radius name".into(),
|
||||
});
|
||||
};
|
||||
let Some(slot) = radii.field_mut(name) else {
|
||||
return Err(EmitError::UnsupportedComposite {
|
||||
key: target.key.clone(),
|
||||
detail: format!("unknown radius `{name}`"),
|
||||
});
|
||||
};
|
||||
// A single `rounding` value applies to all four corners.
|
||||
*slot = [*n; 4];
|
||||
}
|
||||
|
||||
ron::ser::to_string_pretty(&radii, ron::ser::PrettyConfig::new()).map_err(|e| {
|
||||
EmitError::UnsupportedComposite {
|
||||
key: target.key.clone(),
|
||||
detail: e.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
other => Err(EmitError::UnsupportedComposite {
|
||||
key: other.to_string(),
|
||||
detail: format!(
|
||||
"composite shape not modelled yet; {} field(s) would be written blind",
|
||||
fields.len()
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{parse, resolve};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn plan_for(src: &str, root: &Path) -> Result<Vec<Planned>, Vec<EmitError>> {
|
||||
let ast = parse(src).expect("parse");
|
||||
let resolved = resolve(&ast).expect("resolve");
|
||||
Emitter::with_root(root).plan(&resolved)
|
||||
}
|
||||
|
||||
fn read(root: &Path, component: &str, key: &str) -> String {
|
||||
fs::read_to_string(root.join(component).join("v1").join(key))
|
||||
.unwrap_or_else(|e| panic!("reading {component}/v1/{key}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_land_on_the_cosmic_config_path_layout() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||
let e = Emitter::with_root(tmp.path());
|
||||
e.apply(&planned).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
read(tmp.path(), "com.system76.CosmicComp", "autotile"),
|
||||
"true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalars_render_as_ron_literals() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let src = "general {\n autotile = true\n edge_snap_threshold = 12\n}\n\
|
||||
theme {\n icon_theme = Tela-circle-dracula\n}\n";
|
||||
let planned = plan_for(src, tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||
|
||||
let root = tmp.path();
|
||||
assert_eq!(read(root, "com.system76.CosmicComp", "autotile"), "true");
|
||||
assert_eq!(
|
||||
read(root, "com.system76.CosmicComp", "edge_snap_threshold"),
|
||||
"12"
|
||||
);
|
||||
assert_eq!(
|
||||
read(root, "com.system76.CosmicTk", "icon_theme"),
|
||||
"\"Tela-circle-dracula\""
|
||||
);
|
||||
}
|
||||
|
||||
/// The end-to-end form of the folding property: both halves must reach disk
|
||||
/// in one tuple.
|
||||
#[test]
|
||||
fn both_gaps_reach_disk_in_one_tuple() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let planned =
|
||||
plan_for("general {\n gaps_in = 3\n gaps_out = 8\n}\n", tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||
|
||||
// (outer, inner) — theme.rs:895
|
||||
for builder in [
|
||||
"com.system76.CosmicTheme.Dark.Builder",
|
||||
"com.system76.CosmicTheme.Light.Builder",
|
||||
] {
|
||||
assert_eq!(read(tmp.path(), builder, "gaps"), "(8, 3)");
|
||||
}
|
||||
}
|
||||
|
||||
/// cosmic-config is sparse: an unset key has no file, so a partial
|
||||
/// projection must fall back to the verified default rather than zero.
|
||||
#[test]
|
||||
fn partial_projection_uses_the_verified_default() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let planned = plan_for("general {\n gaps_in = 5\n}\n", tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||
|
||||
// Default is (0, 8); only inner was set, so outer stays 0.
|
||||
assert_eq!(
|
||||
read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"),
|
||||
"(0, 5)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Read-modify-write must preserve the half the user did not mention.
|
||||
#[test]
|
||||
fn partial_projection_preserves_existing_sibling() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp
|
||||
.path()
|
||||
.join("com.system76.CosmicTheme.Dark.Builder")
|
||||
.join("v1");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("gaps"), "(20, 4)").unwrap();
|
||||
|
||||
let planned = plan_for("general {\n gaps_in = 7\n}\n", tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||
|
||||
// Outer 20 survives; inner becomes 7.
|
||||
assert_eq!(
|
||||
read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"),
|
||||
"(20, 7)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_render_with_the_right_component_count() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let src = "theme {\n accent = rgb(ff0000)\n bg_color = rgba(00ff0080)\n}\n";
|
||||
let planned = plan_for(src, tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||
|
||||
let b = "com.system76.CosmicTheme.Dark.Builder";
|
||||
// Option<Srgb>: three components, no alpha.
|
||||
assert_eq!(
|
||||
read(tmp.path(), b, "accent"),
|
||||
"Some((red: 1.0, green: 0.0, blue: 0.0))"
|
||||
);
|
||||
// Option<Srgba>: four.
|
||||
let bg = read(tmp.path(), b, "bg_color");
|
||||
assert!(
|
||||
bg.starts_with("Some((red: 0.0, green: 1.0, blue: 0.0, alpha: "),
|
||||
"{bg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_sets_all_four_corners_of_radius_m() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||
|
||||
let text = read(
|
||||
tmp.path(),
|
||||
"com.system76.CosmicTheme.Dark.Builder",
|
||||
"corner_radii",
|
||||
);
|
||||
let radii: CornerRadii = ron::from_str(&text).expect("round-trips as CornerRadii");
|
||||
assert_eq!(radii.radius_m, [10.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounding_preserves_sibling_radii() {
|
||||
// The other five radii must survive a read-modify-write untouched.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||
|
||||
let text = read(
|
||||
tmp.path(),
|
||||
"com.system76.CosmicTheme.Dark.Builder",
|
||||
"corner_radii",
|
||||
);
|
||||
let radii: CornerRadii = ron::from_str(&text).unwrap();
|
||||
let d = CornerRadii::default();
|
||||
assert_eq!(radii.radius_0, d.radius_0);
|
||||
assert_eq!(radii.radius_xs, d.radius_xs);
|
||||
assert_eq!(radii.radius_s, d.radius_s);
|
||||
assert_eq!(radii.radius_l, d.radius_l);
|
||||
assert_eq!(radii.radius_xl, d.radius_xl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_radii_defaults_match_upstream() {
|
||||
// Pinned against cosmic-theme/src/model/corner.rs:20-31. If upstream
|
||||
// changes these, writing a sparse config would silently shift the theme.
|
||||
let d = CornerRadii::default();
|
||||
assert_eq!(d.radius_0, [0.0; 4]);
|
||||
assert_eq!(d.radius_xs, [4.0; 4]);
|
||||
assert_eq!(d.radius_s, [8.0; 4]);
|
||||
assert_eq!(d.radius_m, [16.0; 4]);
|
||||
assert_eq!(d.radius_l, [32.0; 4]);
|
||||
assert_eq!(d.radius_xl, [160.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuinely_unmodelled_composite_is_still_refused() {
|
||||
// A projected target with no shape handler must error rather than
|
||||
// write a partial value.
|
||||
let write = Write {
|
||||
target: TargetKey {
|
||||
component: "com.system76.Whatever".into(),
|
||||
version: 1,
|
||||
key: "palette".into(),
|
||||
},
|
||||
kind: WriteKind::Projected(BTreeMap::from([(
|
||||
vec!["bright_red".to_string()],
|
||||
Value::U32(1),
|
||||
)])),
|
||||
};
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let e = Emitter::with_root(tmp.path());
|
||||
let res = e.plan(&Resolved {
|
||||
writes: vec![write],
|
||||
});
|
||||
assert!(matches!(
|
||||
res.unwrap_err()[0],
|
||||
EmitError::UnsupportedComposite { .. }
|
||||
),);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparseable_existing_value_is_refused() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp
|
||||
.path()
|
||||
.join("com.system76.CosmicTheme.Dark.Builder")
|
||||
.join("v1");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("gaps"), "not ron at all").unwrap();
|
||||
|
||||
let errs = plan_for("general {\n gaps_in = 3\n}\n", tmp.path()).unwrap_err();
|
||||
assert!(
|
||||
matches!(errs[0], EmitError::Unreadable { .. }),
|
||||
"{:?}",
|
||||
errs[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// Planning must not touch disk — that is what makes emission transactional.
|
||||
#[test]
|
||||
fn plan_does_not_write() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _ = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||
assert!(
|
||||
fs::read_dir(tmp.path()).unwrap().next().is_none(),
|
||||
"plan must leave the tree untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_writes_are_skipped() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let src = "general {\n autotile = true\n}\n";
|
||||
|
||||
let planned = plan_for(src, tmp.path()).unwrap();
|
||||
assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 1);
|
||||
|
||||
// Second run sees identical contents and writes nothing.
|
||||
let planned = plan_for(src, tmp.path()).unwrap();
|
||||
assert!(planned[0].is_noop());
|
||||
assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_contents_are_captured_for_diffing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let first = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&first).unwrap();
|
||||
|
||||
let second = plan_for("general {\n autotile = false\n}\n", tmp.path()).unwrap();
|
||||
assert_eq!(second[0].previous.as_deref(), Some("true"));
|
||||
assert_eq!(second[0].contents, "false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_leaves_no_temp_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||
|
||||
let dir = tmp.path().join("com.system76.CosmicComp").join("v1");
|
||||
let leftovers: Vec<_> = fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|n| n.starts_with(".atomicwrite"))
|
||||
.collect();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"temp files left behind: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_honours_xdg_config_home() {
|
||||
// Uses the documented fallback chain rather than a hardcoded path.
|
||||
let prev = std::env::var_os("XDG_CONFIG_HOME");
|
||||
std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-probe");
|
||||
let e = Emitter::from_env().unwrap();
|
||||
assert_eq!(e.root(), Path::new("/tmp/xdg-probe/cosmic"));
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
||||
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! HyDE `hypr.theme` -> `cosmic.conf`.
|
||||
//!
|
||||
//! One-way, and into the conf file rather than straight into cosmic-config, so
|
||||
//! the result is readable and editable before it touches the desktop.
|
||||
//!
|
||||
//! The guiding rule is that **nothing is dropped silently**. A HyDE theme
|
||||
//! contains a good deal that COSMIC has no equivalent for — gradient borders,
|
||||
//! blur tuning, layer rules — and a converter that quietly ignored them would
|
||||
//! leave the user wondering why their desktop looks wrong. Every unhandled key
|
||||
//! is reported with a reason.
|
||||
|
||||
use crate::parser::{parse, Item, ParseError, Span};
|
||||
|
||||
/// Why a source key did not make it into the output.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Reason {
|
||||
/// COSMIC has no equivalent concept.
|
||||
NoEquivalent(&'static str),
|
||||
/// Needs a compositor patch that does not exist yet (spec Phase 2).
|
||||
NeedsCompositorPatch(&'static str),
|
||||
/// Belongs to another program entirely; copied verbatim, not translated.
|
||||
DifferentProgram(&'static str),
|
||||
/// Translated, but with a loss worth knowing about.
|
||||
Lossy(String),
|
||||
}
|
||||
|
||||
impl Reason {
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Reason::NoEquivalent(d) => format!("no COSMIC equivalent: {d}"),
|
||||
Reason::NeedsCompositorPatch(d) => format!("needs a cosmic-comp patch: {d}"),
|
||||
Reason::DifferentProgram(d) => format!("handled by another program: {d}"),
|
||||
Reason::Lossy(d) => format!("translated with loss: {d}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Note {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub reason: Reason,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Import {
|
||||
/// Generated `cosmic.conf` text.
|
||||
pub conf: String,
|
||||
/// Everything that did not translate cleanly.
|
||||
pub notes: Vec<Note>,
|
||||
/// The theme's `$ICON_THEME`, if it names one.
|
||||
///
|
||||
/// Also present in `conf` as `theme.icon_theme`, but repeated here as a
|
||||
/// field because `assets.rs` needs it to generate rofi's `local.rasi` and
|
||||
/// re-parsing the text this function just rendered to get it back would be
|
||||
/// absurd.
|
||||
pub icon_theme: Option<String>,
|
||||
}
|
||||
|
||||
impl Import {
|
||||
/// Keys that produced no output at all, as opposed to lossy translations.
|
||||
pub fn dropped(&self) -> impl Iterator<Item = &Note> {
|
||||
self.notes
|
||||
.iter()
|
||||
.filter(|n| !matches!(n.reason, Reason::Lossy(_)))
|
||||
}
|
||||
}
|
||||
|
||||
/// HyDE prefixes each `.theme` file with a destination line such as
|
||||
/// `$HOME/.config/hypr/themes/theme.conf|> $HOME/.../colors.conf`.
|
||||
///
|
||||
/// It is metadata for HyDE's own installer, not config, and it has no `=`, so
|
||||
/// the parser would reject the file outright. Strip it before parsing.
|
||||
fn strip_hyde_header(src: &str) -> &str {
|
||||
let mut lines = src.lines();
|
||||
let Some(first) = lines.next() else {
|
||||
return src;
|
||||
};
|
||||
let is_destination_header =
|
||||
!first.contains('=') && (first.contains("|>") || first.contains('|'));
|
||||
if is_destination_header {
|
||||
// Preserve line numbering by keeping the newline count intact: callers
|
||||
// report spans against the stripped text, so re-add a blank line.
|
||||
match src.find('\n') {
|
||||
Some(i) => &src[i + 1..],
|
||||
None => "",
|
||||
}
|
||||
} else {
|
||||
src
|
||||
}
|
||||
}
|
||||
|
||||
/// First colour of a possibly-gradient Hyprland border spec.
|
||||
/// `rgba(ca9ee6ff) rgba(f2d5cfff) 45deg` -> `ca9ee6`.
|
||||
fn first_color_rgb(value: &str) -> Option<String> {
|
||||
let token = value.split_whitespace().next()?;
|
||||
let inner = token
|
||||
.strip_prefix("rgba(")
|
||||
.or_else(|| token.strip_prefix("rgb("))?
|
||||
.strip_suffix(')')?;
|
||||
let hex = inner.trim_start_matches('#');
|
||||
if hex.len() >= 6 && hex[..6].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
Some(hex[..6].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gradient(value: &str) -> bool {
|
||||
value.split_whitespace().count() > 1
|
||||
}
|
||||
|
||||
/// Flatten to dotted keys, keeping variables separate — HyDE carries
|
||||
/// `$GTK_THEME` / `$ICON_THEME` as variables rather than config keys.
|
||||
fn walk(
|
||||
items: &[Item],
|
||||
prefix: &str,
|
||||
out: &mut Vec<(String, String, Span)>,
|
||||
vars: &mut Vec<(String, String, Span)>,
|
||||
) {
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Section { name, items } => {
|
||||
let next = if prefix.is_empty() {
|
||||
name.value.clone()
|
||||
} else {
|
||||
format!("{prefix}.{}", name.value)
|
||||
};
|
||||
walk(items, &next, out, vars);
|
||||
}
|
||||
Item::Assign { key, value } => {
|
||||
let dotted = if prefix.is_empty() {
|
||||
key.value.clone()
|
||||
} else {
|
||||
format!("{prefix}.{}", key.value)
|
||||
};
|
||||
out.push((dotted, value.value.clone(), key.span));
|
||||
}
|
||||
Item::VarDef { name, value } => {
|
||||
vars.push((name.value.clone(), value.value.clone(), name.span));
|
||||
}
|
||||
Item::Source { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate a HyDE `hypr.theme` into a `cosmic.conf`.
|
||||
pub fn import_hypr_theme(src: &str, theme_name: &str) -> Result<Import, ParseError> {
|
||||
let body = strip_hyde_header(src);
|
||||
let ast = parse(body)?;
|
||||
|
||||
let mut keys = Vec::new();
|
||||
let mut vars = Vec::new();
|
||||
walk(&ast.items, "", &mut keys, &mut vars);
|
||||
|
||||
let mut general: Vec<(String, String)> = Vec::new();
|
||||
let mut decoration: Vec<(String, String)> = Vec::new();
|
||||
let mut theme: Vec<(String, String)> = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
let mut icon_theme = None;
|
||||
|
||||
for (name, value, span) in &vars {
|
||||
match name.as_str() {
|
||||
"ICON_THEME" => {
|
||||
theme.push(("icon_theme".into(), value.clone()));
|
||||
icon_theme = Some(value.clone());
|
||||
}
|
||||
"COLOR_SCHEME" => {
|
||||
let mode = if value.contains("light") {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
theme.push(("mode".into(), mode.into()));
|
||||
}
|
||||
"GTK_THEME" => notes.push(Note {
|
||||
key: format!("${name}"),
|
||||
value: value.clone(),
|
||||
reason: Reason::DifferentProgram(
|
||||
"GTK theme applies to GTK apps directly; COSMIC apps use cosmic-theme",
|
||||
),
|
||||
span: *span,
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value, span) in &keys {
|
||||
let note = |reason| Note {
|
||||
key: key.clone(),
|
||||
value: value.clone(),
|
||||
reason,
|
||||
span: *span,
|
||||
};
|
||||
|
||||
match key.as_str() {
|
||||
"general.gaps_in" => general.push(("gaps_in".into(), value.clone())),
|
||||
"general.gaps_out" => general.push(("gaps_out".into(), value.clone())),
|
||||
"decoration.rounding" => decoration.push(("rounding".into(), value.clone())),
|
||||
|
||||
// Border colour is the closest thing a HyDE theme has to an accent.
|
||||
"general.col.active_border" => match first_color_rgb(value) {
|
||||
Some(hex) => {
|
||||
theme.push(("accent".into(), format!("rgb({hex})")));
|
||||
if is_gradient(value) {
|
||||
notes.push(note(Reason::Lossy(format!(
|
||||
"used first stop rgb({hex}) as the accent; COSMIC's active_hint \
|
||||
is a solid colour with no gradient or angle"
|
||||
))));
|
||||
}
|
||||
}
|
||||
None => notes.push(note(Reason::NoEquivalent("unrecognised colour syntax"))),
|
||||
},
|
||||
|
||||
"general.col.inactive_border"
|
||||
| "group.col.border_active"
|
||||
| "group.col.border_inactive"
|
||||
| "group.col.border_locked_active"
|
||||
| "group.col.border_locked_inactive" => {
|
||||
notes.push(note(Reason::NoEquivalent(
|
||||
"COSMIC draws a single active hint; per-state border colours do not exist",
|
||||
)));
|
||||
}
|
||||
|
||||
"general.border_size" => notes.push(note(Reason::NoEquivalent(
|
||||
"COSMIC's active_hint is a boolean, not a width",
|
||||
))),
|
||||
"general.layout" => notes.push(note(Reason::NoEquivalent(
|
||||
"cosmic-comp uses a BSP tiler; dwindle/master are not available",
|
||||
))),
|
||||
"general.resize_on_border" => {
|
||||
notes.push(note(Reason::NoEquivalent("no equivalent setting")))
|
||||
}
|
||||
|
||||
k if k.starts_with("decoration.blur") => {
|
||||
notes.push(note(Reason::NeedsCompositorPatch(
|
||||
"COSMIC blur is client-requested via \
|
||||
ext-background-effect; rule-driven blur is spec Phase 2",
|
||||
)))
|
||||
}
|
||||
k if k.starts_with("decoration.shadow") => notes.push(note(
|
||||
Reason::NeedsCompositorPatch("shadow.frag exists but is not configurable yet"),
|
||||
)),
|
||||
"decoration.active_opacity" | "decoration.inactive_opacity" => notes.push(note(
|
||||
Reason::NeedsCompositorPatch("window opacity is not configurable yet"),
|
||||
)),
|
||||
|
||||
"layerrule" => notes.push(note(Reason::DifferentProgram(
|
||||
"layer rules target the bar; waybar is configured directly",
|
||||
))),
|
||||
"exec" => notes.push(note(Reason::DifferentProgram(
|
||||
"HyDE runs gsettings here; icon and GTK themes are handled above",
|
||||
))),
|
||||
|
||||
_ => notes.push(note(Reason::NoEquivalent("unrecognised key"))),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Import {
|
||||
conf: render_conf(theme_name, &general, &decoration, &theme, ¬es),
|
||||
notes,
|
||||
icon_theme,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_conf(
|
||||
theme_name: &str,
|
||||
general: &[(String, String)],
|
||||
decoration: &[(String, String)],
|
||||
theme: &[(String, String)],
|
||||
notes: &[Note],
|
||||
) -> String {
|
||||
let mut out = format!(
|
||||
"# Generated by `hyprcosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\
|
||||
# Edit freely — this file is the source of truth; cosmic-settings changes\n\
|
||||
# are overwritten on the next `hyprcosmic-conf apply`.\n"
|
||||
);
|
||||
|
||||
let dropped: Vec<&Note> = notes
|
||||
.iter()
|
||||
.filter(|n| !matches!(n.reason, Reason::Lossy(_)))
|
||||
.collect();
|
||||
if !dropped.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"#\n# {} setting(s) from the source theme were not translated.\n\
|
||||
# Run with --report to see them.\n",
|
||||
dropped.len()
|
||||
));
|
||||
}
|
||||
|
||||
let section = |name: &str, rows: &[(String, String)], out: &mut String| {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
out.push_str(&format!("\n{name} {{\n"));
|
||||
let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||
for (k, v) in rows {
|
||||
out.push_str(&format!(" {k:<width$} = {v}\n"));
|
||||
}
|
||||
out.push_str("}\n");
|
||||
};
|
||||
|
||||
section("general", general, &mut out);
|
||||
section("decoration", decoration, &mut out);
|
||||
section("theme", theme, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Human-readable report of everything that did not translate cleanly.
|
||||
pub fn render_report(import: &Import) -> String {
|
||||
if import.notes.is_empty() {
|
||||
return "Everything in the source theme translated cleanly.\n".into();
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let lossy: Vec<&Note> = import
|
||||
.notes
|
||||
.iter()
|
||||
.filter(|n| matches!(n.reason, Reason::Lossy(_)))
|
||||
.collect();
|
||||
let dropped: Vec<&Note> = import.dropped().collect();
|
||||
|
||||
if !lossy.is_empty() {
|
||||
out.push_str("Translated with loss:\n");
|
||||
for n in &lossy {
|
||||
out.push_str(&format!(
|
||||
" {} = {}\n {}\n",
|
||||
n.key,
|
||||
n.value,
|
||||
n.reason.describe()
|
||||
));
|
||||
}
|
||||
}
|
||||
if !dropped.is_empty() {
|
||||
if !lossy.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("Not translated:\n");
|
||||
for n in &dropped {
|
||||
out.push_str(&format!(
|
||||
" {} = {}\n {}\n",
|
||||
n.key,
|
||||
n.value,
|
||||
n.reason.describe()
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The genuine Catppuccin-Mocha `hypr.theme` from HyDE-Project/hyde-themes,
|
||||
/// verbatim including its destination header. Synthetic fixtures would miss
|
||||
/// the header, the colon-keys and the gradient syntax.
|
||||
const HYDE_CATPPUCCIN: &str = r#"$HOME/.config/hypr/themes/theme.conf|> $HOME/.config/hypr/themes/colors.conf
|
||||
# // P̳r̳a̳s̳a̳n̳t̳h̳ R̳a̳n̳g̳a̳n̳
|
||||
|
||||
$GTK_THEME=Catppuccin-Mocha
|
||||
$ICON_THEME = Tela-circle-dracula
|
||||
$COLOR_SCHEME = prefer-dark
|
||||
|
||||
exec = gsettings set org.gnome.desktop.interface icon-theme $ICON_THEME
|
||||
|
||||
general {
|
||||
gaps_in = 3
|
||||
gaps_out = 8
|
||||
border_size = 2
|
||||
col.active_border = rgba(ca9ee6ff) rgba(f2d5cfff) 45deg
|
||||
col.inactive_border = rgba(b4befecc) rgba(6c7086cc) 45deg
|
||||
layout = dwindle
|
||||
resize_on_border = true
|
||||
}
|
||||
|
||||
group {
|
||||
col.border_active = rgba(ca9ee6ff) rgba(f2d5cfff) 45deg
|
||||
col.border_inactive = rgba(b4befecc) rgba(6c7086cc) 45deg
|
||||
}
|
||||
|
||||
decoration {
|
||||
rounding = 10
|
||||
shadow:enabled = false
|
||||
|
||||
blur {
|
||||
enabled = yes
|
||||
size = 6
|
||||
passes = 3
|
||||
}
|
||||
}
|
||||
|
||||
layerrule = blur,waybar
|
||||
"#;
|
||||
|
||||
fn import() -> Import {
|
||||
import_hypr_theme(HYDE_CATPPUCCIN, "Catppuccin Mocha").expect("real HyDE theme must parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_hyde_theme_parses_despite_its_destination_header() {
|
||||
// The header has no `=` and would otherwise be a parse error.
|
||||
let _ = import();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_is_translated() {
|
||||
let c = import().conf;
|
||||
assert!(c.contains("gaps_in = 3"), "{c}");
|
||||
assert!(c.contains("gaps_out = 8"), "{c}");
|
||||
assert!(c.contains("rounding = 10"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_comes_from_the_first_gradient_stop() {
|
||||
let c = import().conf;
|
||||
assert!(c.contains("accent"), "{c}");
|
||||
assert!(c.contains("rgb(ca9ee6)"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_loss_is_reported_not_silent() {
|
||||
let i = import();
|
||||
let lossy: Vec<_> = i
|
||||
.notes
|
||||
.iter()
|
||||
.filter(|n| matches!(n.reason, Reason::Lossy(_)))
|
||||
.collect();
|
||||
assert_eq!(lossy.len(), 1, "{:?}", i.notes);
|
||||
assert_eq!(lossy[0].key, "general.col.active_border");
|
||||
assert!(lossy[0].reason.describe().contains("gradient"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_theme_and_color_scheme_come_from_variables() {
|
||||
let c = import().conf;
|
||||
assert!(c.contains("icon_theme = Tela-circle-dracula"), "{c}");
|
||||
assert!(c.contains("mode"), "{c}");
|
||||
assert!(c.contains("dark"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blur_is_flagged_as_needing_a_compositor_patch() {
|
||||
let i = import();
|
||||
let blur: Vec<_> = i
|
||||
.notes
|
||||
.iter()
|
||||
.filter(|n| n.key.starts_with("decoration.blur"))
|
||||
.collect();
|
||||
assert!(!blur.is_empty(), "blur settings must be reported");
|
||||
assert!(blur
|
||||
.iter()
|
||||
.all(|n| matches!(n.reason, Reason::NeedsCompositorPatch(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_concepts_are_each_reported() {
|
||||
let i = import();
|
||||
let keys: Vec<&str> = i.dropped().map(|n| n.key.as_str()).collect();
|
||||
for expected in [
|
||||
"general.border_size",
|
||||
"general.layout",
|
||||
"general.col.inactive_border",
|
||||
"group.col.border_active",
|
||||
"layerrule",
|
||||
] {
|
||||
assert!(
|
||||
keys.contains(&expected),
|
||||
"`{expected}` missing from {keys:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_dropped_without_a_note() {
|
||||
// Every source key must either appear in the output or carry a note.
|
||||
let i = import();
|
||||
let translated = ["general.gaps_in", "general.gaps_out", "decoration.rounding"];
|
||||
let noted: Vec<&str> = i.notes.iter().map(|n| n.key.as_str()).collect();
|
||||
|
||||
let body = strip_hyde_header(HYDE_CATPPUCCIN);
|
||||
let ast = parse(body).unwrap();
|
||||
let (mut keys, mut vars) = (Vec::new(), Vec::new());
|
||||
walk(&ast.items, "", &mut keys, &mut vars);
|
||||
|
||||
for (k, _, _) in &keys {
|
||||
assert!(
|
||||
translated.contains(&k.as_str()) || noted.contains(&k.as_str()),
|
||||
"`{k}` was neither translated nor reported"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_conf_is_valid_input_to_our_own_parser() {
|
||||
// The importer must not emit something `apply` cannot read.
|
||||
let c = import().conf;
|
||||
parse(&c).expect("generated cosmic.conf must parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_conf_resolves_against_the_registry() {
|
||||
// Stronger: every key it emits must actually exist in the schema.
|
||||
let c = import().conf;
|
||||
let ast = parse(&c).unwrap();
|
||||
crate::resolve(&ast).expect("generated conf must resolve cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_separates_lossy_from_dropped() {
|
||||
let r = render_report(&import());
|
||||
assert!(r.contains("Translated with loss:"), "{r}");
|
||||
assert!(r.contains("Not translated:"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_without_pipe_is_not_stripped() {
|
||||
let src = "general {\n gaps_in = 4\n}\n";
|
||||
let i = import_hypr_theme(src, "t").unwrap();
|
||||
// Single key, so no alignment padding.
|
||||
assert!(i.conf.contains("gaps_in = 4"), "{}", i.conf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Compiles a single Hyprland-idiom config file into the cosmic-config tree.
|
||||
//!
|
||||
//! The pipeline is `parser -> schema -> resolve -> emit`, and it is transactional:
|
||||
//! `resolve` validates everything before `emit` writes anything, so a malformed
|
||||
//! file leaves the desktop untouched rather than half-applied.
|
||||
//!
|
||||
//! `parser`, `schema` and `resolve` are pure and depend on nothing
|
||||
//! COSMIC-specific, which keeps the hard logic testable without a compositor
|
||||
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
||||
|
||||
pub mod assets;
|
||||
pub mod bind;
|
||||
pub mod emit;
|
||||
pub mod import;
|
||||
pub mod parser;
|
||||
pub mod resolve;
|
||||
pub mod schema;
|
||||
pub mod watch;
|
||||
pub mod windowrule;
|
||||
pub mod workspace;
|
||||
|
||||
pub use bind::{parse_bind, Bind};
|
||||
pub use emit::{EmitError, Emitter, Planned};
|
||||
pub use import::{import_hypr_theme, render_report, Import};
|
||||
pub use parser::{parse, Ast, ParseError, Span};
|
||||
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
|
||||
pub use windowrule::{parse_window_rule, WindowRuleDecl};
|
||||
pub use workspace::{parse_workspace, WorkspaceDecl};
|
||||
|
||||
/// Render a diagnostic against source text, cargo-style.
|
||||
pub fn render_diagnostic(source: &str, span: Span, message: &str, help: Option<&str>) -> String {
|
||||
let line = source
|
||||
.lines()
|
||||
.nth(span.line.saturating_sub(1))
|
||||
.unwrap_or("");
|
||||
let gutter = span.line.to_string().len();
|
||||
let pad = " ".repeat(gutter);
|
||||
let caret = " ".repeat(span.col.saturating_sub(1)) + &"^".repeat(span.len.max(1));
|
||||
|
||||
let mut out = format!(
|
||||
"error: {message}\n\
|
||||
{pad}--> cosmic.conf:{}:{}\n\
|
||||
{pad} |\n\
|
||||
{} | {line}\n\
|
||||
{pad} | {caret}",
|
||||
span.line, span.col, span.line
|
||||
);
|
||||
if let Some(help) = help {
|
||||
out.push_str(&format!(" {help}"));
|
||||
}
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn end_to_end_valid_config_produces_writes() {
|
||||
let src = "\
|
||||
$accent = rgb(6b9fed)
|
||||
$gap = 4
|
||||
|
||||
general {
|
||||
gaps_in = $gap
|
||||
gaps_out = $gap * 2
|
||||
autotile = true
|
||||
}
|
||||
|
||||
theme {
|
||||
accent = $accent
|
||||
}
|
||||
";
|
||||
let ast = parse(src).expect("parse");
|
||||
let r = resolve(&ast).expect("resolve");
|
||||
assert!(!r.writes.is_empty());
|
||||
|
||||
// gaps fold per builder; accent fans out to both; autotile is direct.
|
||||
let gaps: Vec<_> = r.writes.iter().filter(|w| w.target.key == "gaps").collect();
|
||||
assert_eq!(gaps.len(), 2);
|
||||
let accent: Vec<_> = r
|
||||
.writes
|
||||
.iter()
|
||||
.filter(|w| w.target.key == "accent")
|
||||
.collect();
|
||||
assert_eq!(accent.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_rendering_points_at_the_offending_token() {
|
||||
let src = "general {\n gaps_inn = 8\n}\n";
|
||||
let ast = parse(src).unwrap();
|
||||
let diags = resolve(&ast).unwrap_err();
|
||||
let out = render_diagnostic(
|
||||
src,
|
||||
diags[0].span,
|
||||
&diags[0].message,
|
||||
diags[0].help.as_deref(),
|
||||
);
|
||||
|
||||
assert!(out.contains("unknown key"), "{out}");
|
||||
assert!(out.contains("cosmic.conf:2:5"), "{out}");
|
||||
assert!(out.contains("^^^^^^^^"), "{out}");
|
||||
assert!(out.contains("did you mean"), "{out}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
//! `cosmic-conf` — compile a Hyprland-idiom config file into cosmic-config.
|
||||
//!
|
||||
//! Exit codes: 0 success, 1 config error (nothing written), 2 usage error.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch};
|
||||
|
||||
// Names the installed binary, hyprcosmic-conf, not the crate. The package
|
||||
// installs beside COSMIC and keeps every path it owns under a hyprcosmic-*
|
||||
// name, so a usage line saying `cosmic-conf` would name something that is not
|
||||
// on the system.
|
||||
const USAGE: &str = "\
|
||||
hyprcosmic-conf — compile cosmic.conf into the cosmic-config tree
|
||||
|
||||
USAGE:
|
||||
hyprcosmic-conf apply [--diff] [--config <path>]
|
||||
hyprcosmic-conf watch [--config <path>]
|
||||
hyprcosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
|
||||
[--assets [--source <dir>] [--overwrite] [--dry-run]]
|
||||
|
||||
COMMANDS:
|
||||
apply Compile the config once and exit
|
||||
watch Stay running and recompile on every edit, to the config
|
||||
and to anything it sources. A malformed edit is reported
|
||||
and waited past, not fatal.
|
||||
import-theme Translate a HyDE theme into config keys
|
||||
|
||||
OPTIONS:
|
||||
--diff Show what would change without writing anything
|
||||
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
|
||||
--out <path> Write the generated cosmic.conf here (default: stdout)
|
||||
--report Print everything that did not translate cleanly
|
||||
--assets Also install wallpapers, GTK/icon themes and the
|
||||
waybar/rofi/kitty theme files that sit beside hypr.theme
|
||||
--source <dir> The theme repo's Source/ directory holding the GTK and
|
||||
icon tarballs (default: found by searching upward)
|
||||
--overwrite Replace assets that are already installed
|
||||
--dry-run With --assets, list what would be installed and stop
|
||||
-h, --help Show this help
|
||||
";
|
||||
|
||||
/// HyDE keeps GTK and icon tarballs in a `Source/` directory at the root of
|
||||
/// the theme repo, four levels above the theme folder
|
||||
/// (`Configs/.config/hyde/themes/<Name>/`). Searching upward rather than
|
||||
/// hardcoding that depth means a theme unpacked at a different depth, or one
|
||||
/// vendored into another tree, still works.
|
||||
fn find_source_dir(theme_dir: &std::path::Path) -> Option<PathBuf> {
|
||||
theme_dir
|
||||
.ancestors()
|
||||
.take(6)
|
||||
.map(|a| a.join("Source"))
|
||||
.find(|c| c.is_dir())
|
||||
}
|
||||
|
||||
/// Refuse arguments the caller does not understand.
|
||||
///
|
||||
/// `apply` writes to the config tree, so an argument it does not recognise has
|
||||
/// to stop it rather than be skipped: `--diff-only` instead of `--diff`, or a
|
||||
/// config path given positionally, would otherwise apply to the *default*
|
||||
/// config while looking like it had done what was asked. This is a
|
||||
/// hand-rolled check rather than a dependency because the whole surface is six
|
||||
/// flags, and an argument parser that silently ignores the unknown is exactly
|
||||
/// the behaviour being removed.
|
||||
///
|
||||
/// `flags` take no value; `valued` consume the argument after them.
|
||||
fn reject_unknown(args: &[String], flags: &[&str], valued: &[&str]) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let a = &args[i];
|
||||
if valued.contains(&a.as_str()) {
|
||||
i += 2;
|
||||
} else if flags.contains(&a.as_str()) || a == "-h" || a == "--help" {
|
||||
// `--help` is accepted by every subcommand, so callers do not have
|
||||
// to list it; `main` has already acted on it by this point.
|
||||
i += 1;
|
||||
} else if let Some(name) = a.strip_prefix("--") {
|
||||
return Err(format!("error: unknown option `--{name}`"));
|
||||
} else {
|
||||
return Err(format!("error: unexpected argument `{a}`"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_config_path() -> Option<PathBuf> {
|
||||
let base = match std::env::var_os("XDG_CONFIG_HOME") {
|
||||
Some(x) if !x.is_empty() => PathBuf::from(x),
|
||||
_ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
|
||||
};
|
||||
Some(base.join("hyprcosmic").join("cosmic.conf"))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") {
|
||||
print!("{USAGE}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
if args[0] == "import-theme" {
|
||||
return match run_import(&args[1..]) {
|
||||
Ok(msg) => {
|
||||
print!("{msg}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(msg) => {
|
||||
eprint!("{msg}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
};
|
||||
}
|
||||
let command = args[0].as_str();
|
||||
if !matches!(command, "apply" | "watch") {
|
||||
eprintln!("error: unknown command `{command}`\n\n{USAGE}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
|
||||
// `--diff` belongs to `apply` alone: a daemon whose whole job is to notice
|
||||
// a change and write it has nothing to do with a mode that declines to
|
||||
// write. Passing it to `watch` is an error rather than a no-op, for the
|
||||
// same reason `--diff-only` is.
|
||||
let flags: &[&str] = if command == "apply" { &["--diff"] } else { &[] };
|
||||
if let Err(msg) = reject_unknown(&args[1..], flags, &["--config"]) {
|
||||
eprintln!("{msg}\n\n{USAGE}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
|
||||
let diff_only = args.iter().any(|a| a == "--diff");
|
||||
let config_path = match args.iter().position(|a| a == "--config") {
|
||||
Some(i) => match args.get(i + 1) {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => {
|
||||
eprintln!("error: --config needs a path");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
},
|
||||
None => match default_config_path() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("error: cannot determine config path (no HOME or XDG_CONFIG_HOME)");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if command == "watch" {
|
||||
return match run_watch(&config_path) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(msg) => {
|
||||
eprint!("{msg}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match run(&config_path, diff_only) {
|
||||
Ok(msg) => {
|
||||
println!("{msg}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(msg) => {
|
||||
eprint!("{msg}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(config_path: &Path, diff_only: bool) -> Result<String, String> {
|
||||
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
|
||||
|
||||
// Through `watch::compile` rather than parse/resolve/plan inline, because
|
||||
// that is the only path that expands `source`. Doing it by hand here meant
|
||||
// `resolve` never saw the included text -- `flatten` drops `Item::Source`
|
||||
// -- so a sourced file was silently ignored by `apply` while `watch`
|
||||
// honoured it. An include that works in one and vanishes in the other is
|
||||
// worse than one that is unsupported in both.
|
||||
let compiled = watch::compile(config_path, &emitter).map_err(|e| e.to_string())?;
|
||||
let planned = compiled.planned;
|
||||
|
||||
let changes: Vec<_> = planned.iter().filter(|p| !p.is_noop()).collect();
|
||||
|
||||
if diff_only {
|
||||
if changes.is_empty() {
|
||||
return Ok("No changes.".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for p in &changes {
|
||||
let rel = p
|
||||
.path
|
||||
.strip_prefix(emitter.root())
|
||||
.unwrap_or(&p.path)
|
||||
.display();
|
||||
out.push_str(&format!("~ {rel}\n"));
|
||||
match &p.previous {
|
||||
Some(prev) => out.push_str(&format!(" - {}\n", prev.trim())),
|
||||
None => out.push_str(" - (unset)\n"),
|
||||
}
|
||||
out.push_str(&format!(" + {}\n", p.contents.trim()));
|
||||
}
|
||||
out.push_str(&format!("\n{} file(s) would change.", changes.len()));
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
let written = emitter
|
||||
.apply(&planned)
|
||||
.map_err(|e| format!("error: {e}\n"))?;
|
||||
Ok(format!(
|
||||
"Applied {written} change(s) to {}.",
|
||||
emitter.root().display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Block, recompiling on every edit, until the watcher itself stops.
|
||||
///
|
||||
/// Returns nothing to print on success because there is no success to report
|
||||
/// until it is over: progress goes to stderr as it happens, from inside the
|
||||
/// loop. A config error is not an error here either -- `watch` reports a
|
||||
/// malformed edit and waits for the next one, which is the whole point of
|
||||
/// leaving it running -- so the only failure that reaches this function is the
|
||||
/// notify machinery failing to start.
|
||||
fn run_watch(config_path: &Path) -> Result<(), String> {
|
||||
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
|
||||
watch::watch(config_path, &emitter).map_err(|e| format!("{e}\n"))
|
||||
}
|
||||
|
||||
fn run_import(args: &[String]) -> Result<String, String> {
|
||||
let Some(src_path) = args.first().filter(|a| !a.starts_with("--")) else {
|
||||
return Err(format!("error: import-theme needs a path\n\n{USAGE}"));
|
||||
};
|
||||
reject_unknown(
|
||||
&args[1..],
|
||||
&["--report", "--assets", "--overwrite", "--dry-run"],
|
||||
&["--out", "--source"],
|
||||
)
|
||||
.map_err(|msg| format!("{msg}\n\n{USAGE}"))?;
|
||||
let out_path = args
|
||||
.iter()
|
||||
.position(|a| a == "--out")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(PathBuf::from);
|
||||
let want_report = args.iter().any(|a| a == "--report");
|
||||
|
||||
let source = std::fs::read_to_string(src_path)
|
||||
.map_err(|e| format!("error: cannot read {src_path}: {e}\n"))?;
|
||||
|
||||
// HyDE names a theme by its containing directory.
|
||||
let name = PathBuf::from(src_path)
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "imported".into());
|
||||
|
||||
let imported = import::import_hypr_theme(&source, &name)
|
||||
.map_err(|e| render_diagnostic(&source, e.span, &e.message, None))?;
|
||||
|
||||
let mut out = String::new();
|
||||
match out_path {
|
||||
Some(p) => {
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("error: cannot create {}: {e}\n", dir.display()))?;
|
||||
}
|
||||
std::fs::write(&p, &imported.conf)
|
||||
.map_err(|e| format!("error: cannot write {}: {e}\n", p.display()))?;
|
||||
out.push_str(&format!("Wrote {}\n", p.display()));
|
||||
if let Some(hint) = unsourced_hint(&p) {
|
||||
out.push_str(&hint);
|
||||
}
|
||||
}
|
||||
None => out.push_str(&imported.conf),
|
||||
}
|
||||
|
||||
let dropped = imported.dropped().count();
|
||||
if want_report {
|
||||
out.push('\n');
|
||||
out.push_str(&import::render_report(&imported));
|
||||
} else if dropped > 0 {
|
||||
out.push_str(&format!(
|
||||
"\n{dropped} setting(s) did not translate. Re-run with --report for details.\n"
|
||||
));
|
||||
}
|
||||
|
||||
if args.iter().any(|a| a == "--assets") {
|
||||
out.push('\n');
|
||||
out.push_str(&install_assets(
|
||||
src_path,
|
||||
&name,
|
||||
imported.icon_theme.as_deref(),
|
||||
args,
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Warn when the file just written is not reachable from its sibling
|
||||
/// cosmic.conf, and say what to add.
|
||||
///
|
||||
/// A theme lives in its own file so that re-importing cannot clobber the
|
||||
/// keybindings around it, but that only works if something sources it.
|
||||
/// Writing an inert file and reporting success is the worst of both: the tool
|
||||
/// looks like it worked and the desktop does not change.
|
||||
///
|
||||
/// The match is textual and deliberately loose -- it is looking for evidence
|
||||
/// that the user already knows about the file, not parsing the config. A false
|
||||
/// negative costs one redundant hint; a false positive would hide a real
|
||||
/// problem, so the substring searched for is the filename itself.
|
||||
fn unsourced_hint(written: &Path) -> Option<String> {
|
||||
let dir = written.parent()?;
|
||||
let name = written.file_name()?.to_string_lossy().to_string();
|
||||
let main = dir.join("cosmic.conf");
|
||||
|
||||
// Nothing to say when the theme *is* the config, or there is no config yet
|
||||
// to add a line to: `apply` will be pointed at this file directly.
|
||||
if main == written || !main.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = std::fs::read_to_string(&main).ok()?;
|
||||
if text
|
||||
.lines()
|
||||
.any(|l| l.trim_start().starts_with("source") && l.contains(&name))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"\nNothing sources it yet, so `apply` will ignore it. Add this to {}:\n\n source = {}\n",
|
||||
main.display(),
|
||||
written.display(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, the
|
||||
/// `.theme` files belonging to waybar, rofi and kitty, and the small rofi
|
||||
/// files HyprCosmic has to compose itself.
|
||||
///
|
||||
/// Separate from the conf translation because it is separate in kind — almost
|
||||
/// none of it is translated, only placed — and because it writes outside the
|
||||
/// cosmic-config tree, which every other path in this tool does not.
|
||||
///
|
||||
/// `icon_theme` comes back out of the conf translation rather than being read
|
||||
/// from the theme directory again, because that is where the `$ICON_THEME`
|
||||
/// variable was already resolved.
|
||||
fn install_assets(
|
||||
src_path: &str,
|
||||
name: &str,
|
||||
icon_theme: Option<&str>,
|
||||
args: &[String],
|
||||
) -> Result<String, String> {
|
||||
let theme_dir = PathBuf::from(src_path)
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| format!("error: {src_path} has no parent directory\n"))?;
|
||||
|
||||
let source_dir = match args.iter().position(|a| a == "--source") {
|
||||
Some(i) => match args.get(i + 1) {
|
||||
Some(p) => Some(PathBuf::from(p)),
|
||||
None => return Err(format!("error: --source needs a path\n\n{USAGE}")),
|
||||
},
|
||||
None => find_source_dir(&theme_dir),
|
||||
};
|
||||
|
||||
let installer = assets::Installer::from_env().map_err(|e| format!("error: {e}\n"))?;
|
||||
let plan = installer
|
||||
.plan(
|
||||
&theme_dir,
|
||||
source_dir.as_deref(),
|
||||
name,
|
||||
icon_theme,
|
||||
args.iter().any(|a| a == "--overwrite"),
|
||||
)
|
||||
.map_err(|errors| {
|
||||
errors
|
||||
.iter()
|
||||
.map(|e| format!("error: {e}\n"))
|
||||
.collect::<String>()
|
||||
})?;
|
||||
|
||||
if args.iter().any(|a| a == "--dry-run") {
|
||||
return Ok(assets::render_plan(&plan));
|
||||
}
|
||||
|
||||
let report = installer
|
||||
.apply(&plan)
|
||||
.map_err(|e| format!("error: {e}\n"))?;
|
||||
Ok(assets::render_report(&plan, &report))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(s: &[&str]) -> Vec<String> {
|
||||
s.iter().map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_flags_and_their_values_are_accepted() {
|
||||
let a = args(&["--diff", "--config", "/etc/cosmic.conf"]);
|
||||
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_misspelt_flag_is_refused_rather_than_skipped() {
|
||||
// The bug this exists to prevent: `--diff-only` used to be ignored, so
|
||||
// `apply` wrote for real while the caller believed it was a dry run.
|
||||
let a = args(&["--diff-only"]);
|
||||
let err = reject_unknown(&a, &["--diff"], &["--config"]).unwrap_err();
|
||||
assert!(err.contains("--diff-only"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_positional_path_is_refused_because_it_would_be_ignored() {
|
||||
let a = args(&["/home/me/.config/hyprcosmic/cosmic.conf"]);
|
||||
let err = reject_unknown(&a, &["--diff"], &["--config"]).unwrap_err();
|
||||
assert!(err.contains("unexpected argument"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_that_looks_like_a_flag_is_still_a_value() {
|
||||
// `--config --diff` is a user error, but it is the *next* argument's
|
||||
// job to be a path; consuming it here keeps the rule simple and
|
||||
// matches what the position-based lookup below actually does.
|
||||
let a = args(&["--config", "--diff"]);
|
||||
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_valued_flag_with_no_value_is_not_a_panic() {
|
||||
let a = args(&["--config"]);
|
||||
assert!(reject_unknown(&a, &["--diff"], &["--config"]).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! `cosmic.conf` text -> AST.
|
||||
//!
|
||||
//! Hyprland-idiom, line-based grammar:
|
||||
//!
|
||||
//! ```text
|
||||
//! # comment
|
||||
//! $var = value
|
||||
//! section {
|
||||
//! key = value
|
||||
//! nested { key = value }
|
||||
//! }
|
||||
//! bind = SUPER, Q, close # repeatable keys are kept in order
|
||||
//! source = ~/other.conf
|
||||
//! ```
|
||||
//!
|
||||
//! Values are kept as raw strings here; typing happens in `resolve`, which needs
|
||||
//! the schema to know what a value should be.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Byte-independent source position. Line and column are 1-based so they match
|
||||
/// what an editor shows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub line: usize,
|
||||
pub col: usize,
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
pub fn new(line: usize, col: usize, len: usize) -> Self {
|
||||
Self { line, col, len }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Spanned<T> {
|
||||
pub value: T,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl<T> Spanned<T> {
|
||||
pub fn new(value: T, span: Span) -> Self {
|
||||
Self { value, span }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Item {
|
||||
/// `$name = value`
|
||||
VarDef {
|
||||
name: Spanned<String>,
|
||||
value: Spanned<String>,
|
||||
},
|
||||
/// `key = value` inside the current section
|
||||
Assign {
|
||||
key: Spanned<String>,
|
||||
value: Spanned<String>,
|
||||
},
|
||||
/// `name { .. }`
|
||||
Section {
|
||||
name: Spanned<String>,
|
||||
items: Vec<Item>,
|
||||
},
|
||||
/// `source = path`
|
||||
Source { path: Spanned<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Ast {
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParseError {
|
||||
pub message: String,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}: {}", self.span.line, self.span.col, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParseError {}
|
||||
|
||||
/// Strip a trailing `#` comment, respecting nothing else — the grammar has no
|
||||
/// string literals, so there is no quoting to honour.
|
||||
fn strip_comment(line: &str) -> &str {
|
||||
match line.find('#') {
|
||||
Some(i) => &line[..i],
|
||||
None => line,
|
||||
}
|
||||
}
|
||||
|
||||
/// Column (1-based) of the first non-whitespace byte.
|
||||
fn indent_col(line: &str) -> usize {
|
||||
line.len() - line.trim_start().len() + 1
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Result<Ast, ParseError> {
|
||||
let mut cursor = Cursor {
|
||||
lines: input.lines().collect(),
|
||||
idx: 0,
|
||||
};
|
||||
let items = parse_items(&mut cursor, 0)?;
|
||||
Ok(Ast { items })
|
||||
}
|
||||
|
||||
struct Cursor<'a> {
|
||||
lines: Vec<&'a str>,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
/// Parse items until EOF (`depth == 0`) or a closing brace.
|
||||
fn parse_items(cur: &mut Cursor, depth: usize) -> Result<Vec<Item>, ParseError> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
while cur.idx < cur.lines.len() {
|
||||
let raw = cur.lines[cur.idx];
|
||||
let line_no = cur.idx + 1;
|
||||
let content = strip_comment(raw).trim_end();
|
||||
let trimmed = content.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
cur.idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "}" {
|
||||
if depth == 0 {
|
||||
return Err(ParseError {
|
||||
message: "unmatched `}`".into(),
|
||||
span: Span::new(line_no, indent_col(content), 1),
|
||||
});
|
||||
}
|
||||
cur.idx += 1;
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
// `name {` opens a section. A one-line `name { .. }` is not supported;
|
||||
// keeping the grammar strictly line-based keeps spans honest.
|
||||
if let Some(name) = trimmed.strip_suffix('{') {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ParseError {
|
||||
message: "section is missing a name".into(),
|
||||
span: Span::new(line_no, indent_col(content), 1),
|
||||
});
|
||||
}
|
||||
let span = Span::new(line_no, indent_col(content), name.len());
|
||||
cur.idx += 1;
|
||||
let inner = parse_items(cur, depth + 1)?;
|
||||
items.push(Item::Section {
|
||||
name: Spanned::new(name.to_string(), span),
|
||||
items: inner,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(eq) = content.find('=') else {
|
||||
return Err(ParseError {
|
||||
message: format!("expected `key = value`, found `{trimmed}`"),
|
||||
span: Span::new(line_no, indent_col(content), trimmed.len()),
|
||||
});
|
||||
};
|
||||
|
||||
let key_raw = &content[..eq];
|
||||
let val_raw = &content[eq + 1..];
|
||||
let key = key_raw.trim();
|
||||
let value = val_raw.trim();
|
||||
|
||||
if key.is_empty() {
|
||||
return Err(ParseError {
|
||||
message: "assignment is missing a key".into(),
|
||||
span: Span::new(line_no, 1, eq.max(1)),
|
||||
});
|
||||
}
|
||||
|
||||
let key_col = indent_col(content);
|
||||
let key_span = Span::new(line_no, key_col, key.len());
|
||||
// Column of the value = everything before it, plus its own leading trim.
|
||||
let val_col = eq + 2 + (val_raw.len() - val_raw.trim_start().len());
|
||||
let val_span = Span::new(line_no, val_col, value.len());
|
||||
|
||||
let item = if let Some(var) = key.strip_prefix('$') {
|
||||
if var.is_empty() {
|
||||
return Err(ParseError {
|
||||
message: "variable is missing a name after `$`".into(),
|
||||
span: key_span,
|
||||
});
|
||||
}
|
||||
Item::VarDef {
|
||||
name: Spanned::new(var.to_string(), key_span),
|
||||
value: Spanned::new(value.to_string(), val_span),
|
||||
}
|
||||
} else if key == "source" {
|
||||
Item::Source {
|
||||
path: Spanned::new(value.to_string(), val_span),
|
||||
}
|
||||
} else {
|
||||
Item::Assign {
|
||||
key: Spanned::new(key.to_string(), key_span),
|
||||
value: Spanned::new(value.to_string(), val_span),
|
||||
}
|
||||
};
|
||||
|
||||
items.push(item);
|
||||
cur.idx += 1;
|
||||
}
|
||||
|
||||
if depth != 0 {
|
||||
let last = cur.lines.len().max(1);
|
||||
return Err(ParseError {
|
||||
message: "unclosed section: expected `}`".into(),
|
||||
span: Span::new(last, 1, 1),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assign(items: &[Item], key: &str) -> String {
|
||||
items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Item::Assign { key: k, value } if k.value == key => Some(value.value.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no assignment named `{key}`"))
|
||||
}
|
||||
|
||||
fn section<'a>(items: &'a [Item], name: &str) -> &'a [Item] {
|
||||
items
|
||||
.iter()
|
||||
.find_map(|i| match i {
|
||||
Item::Section { name: n, items } if n.value == name => Some(items.as_slice()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no section named `{name}`"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_flat_assignments() {
|
||||
let ast = parse("autotile = true\nrounding = 10\n").unwrap();
|
||||
assert_eq!(assign(&ast.items, "autotile"), "true");
|
||||
assert_eq!(assign(&ast.items, "rounding"), "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_variables() {
|
||||
let ast = parse("$accent = rgb(6b9fed)\n").unwrap();
|
||||
match &ast.items[0] {
|
||||
Item::VarDef { name, value } => {
|
||||
assert_eq!(name.value, "accent");
|
||||
assert_eq!(value.value, "rgb(6b9fed)");
|
||||
}
|
||||
other => panic!("expected VarDef, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_nested_sections() {
|
||||
let src = "decoration {\n rounding = 10\n blur {\n size = 6\n }\n}\n";
|
||||
let ast = parse(src).unwrap();
|
||||
let deco = section(&ast.items, "decoration");
|
||||
assert_eq!(assign(deco, "rounding"), "10");
|
||||
assert_eq!(assign(section(deco, "blur"), "size"), "6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_comments_but_keeps_values() {
|
||||
let ast = parse("gaps_in = 3 # inner gap\n# whole line\n").unwrap();
|
||||
assert_eq!(assign(&ast.items, "gaps_in"), "3");
|
||||
assert_eq!(ast.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_is_its_own_item() {
|
||||
let ast = parse("source = ~/.config/hyprcosmic/monitors.conf\n").unwrap();
|
||||
match &ast.items[0] {
|
||||
Item::Source { path } => assert_eq!(path.value, "~/.config/hyprcosmic/monitors.conf"),
|
||||
other => panic!("expected Source, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeatable_keys_are_preserved_in_order() {
|
||||
let ast = parse("bind = SUPER, Return, spawn, kitty\nbind = SUPER, Q, close\n").unwrap();
|
||||
let binds: Vec<_> = ast
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
Item::Assign { key, value } if key.value == "bind" => Some(value.value.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
binds,
|
||||
vec!["SUPER, Return, spawn, kitty", "SUPER, Q, close"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_point_at_the_key() {
|
||||
let ast = parse("general {\n gaps_inn = 8\n}\n").unwrap();
|
||||
let inner = section(&ast.items, "general");
|
||||
match &inner[0] {
|
||||
Item::Assign { key, .. } => {
|
||||
assert_eq!(key.span.line, 2);
|
||||
assert_eq!(key.span.col, 5);
|
||||
assert_eq!(key.span.len, "gaps_inn".len());
|
||||
}
|
||||
other => panic!("expected Assign, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unclosed_section() {
|
||||
let err = parse("general {\n gaps_in = 3\n").unwrap_err();
|
||||
assert!(err.message.contains("unclosed section"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unmatched_brace() {
|
||||
let err = parse("}\n").unwrap_err();
|
||||
assert!(err.message.contains("unmatched"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_line_without_equals() {
|
||||
let err = parse("this is not valid\n").unwrap_err();
|
||||
assert!(
|
||||
err.message.contains("expected `key = value`"),
|
||||
"{}",
|
||||
err.message
|
||||
);
|
||||
assert_eq!(err.span.line, 1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
||||
//! Declarative registry mapping `cosmic.conf` keys onto cosmic-config targets.
|
||||
//!
|
||||
//! This is data, not code: adding a knob is a table row. Every fact encoded here
|
||||
//! was verified against a checkout rather than assumed — see the spec's
|
||||
//! "Verified findings" table for file:line evidence.
|
||||
|
||||
/// Scalar types a conf value can carry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Ty {
|
||||
Bool,
|
||||
U32,
|
||||
F32,
|
||||
Str,
|
||||
/// `rgb(rrggbb)` -> `Option<Srgb>` (no alpha). Bare `#rrggbb` is not
|
||||
/// accepted: `#` begins a comment.
|
||||
Rgb,
|
||||
/// `rgb(rrggbb)`/`rgba(rrggbbaa)` -> `Option<Srgba>` (with alpha).
|
||||
Rgba,
|
||||
/// `dark`/`light` -> the `is_dark` boolean.
|
||||
Mode,
|
||||
/// Hyprland's `input:follow_mouse`, `0`-`3` -> `focus_follows_cursor`.
|
||||
///
|
||||
/// COSMIC's setting is a plain boolean, so only `0` and `1` have a meaning
|
||||
/// here. Hyprland's `2` and `3` split pointer focus from keyboard focus,
|
||||
/// which cosmic-comp cannot express -- it has one focus and moves it or
|
||||
/// does not. They are rejected rather than rounded to `1`: silently
|
||||
/// granting click-to-focus to someone who asked for the opposite is worse
|
||||
/// than telling them the setting does not exist here.
|
||||
FollowMouse,
|
||||
}
|
||||
|
||||
/// Where a conf key's value lands in the cosmic-config tree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// The conf key owns the entire cosmic-config value.
|
||||
Direct {
|
||||
component: &'static str,
|
||||
version: u8,
|
||||
key: &'static str,
|
||||
},
|
||||
/// The conf key owns one field within a composite value. Requires
|
||||
/// read-modify-write, and multiple conf keys may share one target.
|
||||
Projected {
|
||||
component: &'static str,
|
||||
version: u8,
|
||||
key: &'static str,
|
||||
path: &'static [&'static str],
|
||||
},
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub fn component(&self) -> &'static str {
|
||||
match self {
|
||||
Target::Direct { component, .. } | Target::Projected { component, .. } => component,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn version(&self) -> u8 {
|
||||
match self {
|
||||
Target::Direct { version, .. } | Target::Projected { version, .. } => *version,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &'static str {
|
||||
match self {
|
||||
Target::Direct { key, .. } | Target::Projected { key, .. } => key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inclusive numeric bounds, checked during `resolve`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Range {
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Entry {
|
||||
/// Dotted path as written in the file, e.g. `general.gaps_in`.
|
||||
pub conf: &'static str,
|
||||
/// One conf key may fan out to several components — Dark and Light theme
|
||||
/// builders are separate cosmic-config components holding the same field.
|
||||
pub targets: &'static [Target],
|
||||
pub ty: Ty,
|
||||
pub validate: Option<Range>,
|
||||
/// Generates `cosmic.conf.default`, so the reference file cannot drift.
|
||||
pub doc: &'static str,
|
||||
}
|
||||
|
||||
const DARK_BUILDER: &str = "com.system76.CosmicTheme.Dark.Builder";
|
||||
const LIGHT_BUILDER: &str = "com.system76.CosmicTheme.Light.Builder";
|
||||
const COMP: &str = "com.system76.CosmicComp";
|
||||
const TK: &str = "com.system76.CosmicTk";
|
||||
const THEME_MODE: &str = "com.system76.CosmicTheme.Mode";
|
||||
|
||||
/// `ThemeBuilder.gaps` is `(u32, u32)` ordered **(outer, inner)** —
|
||||
/// `cosmic-theme/src/model/theme.rs:895`. Index 0 is the outer gap.
|
||||
const GAPS_OUTER_IDX: &str = "0";
|
||||
const GAPS_INNER_IDX: &str = "1";
|
||||
|
||||
macro_rules! both_themes {
|
||||
($key:literal, $path:expr) => {
|
||||
&[
|
||||
Target::Projected {
|
||||
component: DARK_BUILDER,
|
||||
version: 1,
|
||||
key: $key,
|
||||
path: $path,
|
||||
},
|
||||
Target::Projected {
|
||||
component: LIGHT_BUILDER,
|
||||
version: 1,
|
||||
key: $key,
|
||||
path: $path,
|
||||
},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/// Whole-value fan-out across both theme builders. An empty projection path
|
||||
/// would be a lie: these fields are `Option<..>` written in full.
|
||||
macro_rules! both_themes_direct {
|
||||
($key:literal) => {
|
||||
&[
|
||||
Target::Direct {
|
||||
component: DARK_BUILDER,
|
||||
version: 1,
|
||||
key: $key,
|
||||
},
|
||||
Target::Direct {
|
||||
component: LIGHT_BUILDER,
|
||||
version: 1,
|
||||
key: $key,
|
||||
},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
pub const REGISTRY: &[Entry] = &[
|
||||
// ---- general ---------------------------------------------------------
|
||||
Entry {
|
||||
conf: "general.gaps_in",
|
||||
targets: both_themes!("gaps", &[GAPS_INNER_IDX]),
|
||||
ty: Ty::U32,
|
||||
validate: Some(Range {
|
||||
min: 0.0,
|
||||
max: 128.0,
|
||||
}),
|
||||
doc: "Gap between adjacent tiled windows, in px",
|
||||
},
|
||||
Entry {
|
||||
conf: "general.gaps_out",
|
||||
targets: both_themes!("gaps", &[GAPS_OUTER_IDX]),
|
||||
ty: Ty::U32,
|
||||
validate: Some(Range {
|
||||
min: 0.0,
|
||||
max: 256.0,
|
||||
}),
|
||||
doc: "Gap between tiled windows and the screen edge, in px",
|
||||
},
|
||||
Entry {
|
||||
conf: "general.autotile",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "autotile",
|
||||
}],
|
||||
ty: Ty::Bool,
|
||||
validate: None,
|
||||
doc: "Automatically tile new windows",
|
||||
},
|
||||
Entry {
|
||||
conf: "general.preserve_split",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "preserve_split",
|
||||
}],
|
||||
ty: Ty::Bool,
|
||||
validate: None,
|
||||
doc: "Open new windows alongside the focused one instead of splitting it",
|
||||
},
|
||||
Entry {
|
||||
conf: "general.active_hint",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "active_hint",
|
||||
}],
|
||||
ty: Ty::Bool,
|
||||
validate: None,
|
||||
doc: "Draw a hint around the focused window",
|
||||
},
|
||||
Entry {
|
||||
conf: "general.focus_follows_cursor",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "focus_follows_cursor",
|
||||
}],
|
||||
ty: Ty::Bool,
|
||||
validate: None,
|
||||
doc: "Move keyboard focus when the cursor enters a window",
|
||||
},
|
||||
Entry {
|
||||
conf: "general.focus_follows_cursor_delay",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "focus_follows_cursor_delay",
|
||||
}],
|
||||
ty: Ty::U32,
|
||||
validate: Some(Range {
|
||||
min: 0.0,
|
||||
max: 5000.0,
|
||||
}),
|
||||
doc: "Delay in ms before focus follows the cursor",
|
||||
},
|
||||
Entry {
|
||||
conf: "general.cursor_follows_focus",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "cursor_follows_focus",
|
||||
}],
|
||||
ty: Ty::Bool,
|
||||
validate: None,
|
||||
doc: "Warp the cursor to the window that gains keyboard focus",
|
||||
},
|
||||
Entry {
|
||||
conf: "general.edge_snap_threshold",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "edge_snap_threshold",
|
||||
}],
|
||||
ty: Ty::U32,
|
||||
validate: Some(Range {
|
||||
min: 0.0,
|
||||
max: 256.0,
|
||||
}),
|
||||
doc: "Distance in px at which windows snap to output edges",
|
||||
},
|
||||
// ---- decoration ------------------------------------------------------
|
||||
Entry {
|
||||
conf: "decoration.rounding",
|
||||
targets: both_themes!("corner_radii", &["radius_m"]),
|
||||
ty: Ty::F32,
|
||||
validate: Some(Range {
|
||||
min: 0.0,
|
||||
max: 64.0,
|
||||
}),
|
||||
doc: "Window corner radius in px (maps to the theme's radius_m)",
|
||||
},
|
||||
// ---- input -----------------------------------------------------------
|
||||
//
|
||||
// Aliases, not new settings: both of these land on the same cosmic-config
|
||||
// keys as `general.focus_follows_cursor` and its delay, which stay for
|
||||
// anyone who prefers COSMIC's own naming. They exist because Hyprland puts
|
||||
// this in `input` under a different name, and accepting the Hyprland
|
||||
// spelling is the point of the fork.
|
||||
//
|
||||
// Two spellings writing one target is safe here only because the last
|
||||
// assignment wins: setting both in one file is not an error, it just means
|
||||
// whichever comes last is what the compositor gets. That is the same rule
|
||||
// the rest of the file follows, so it needs no special handling.
|
||||
Entry {
|
||||
conf: "input.follow_mouse",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "focus_follows_cursor",
|
||||
}],
|
||||
ty: Ty::FollowMouse,
|
||||
// No `Range`: `check_range` only inspects numeric values and this
|
||||
// resolves to a bool, so a range here would be silently ignored. The
|
||||
// accepted values are enforced by `Ty::FollowMouse` itself.
|
||||
validate: None,
|
||||
doc: "1 for focus follows mouse, 0 for click to focus",
|
||||
},
|
||||
Entry {
|
||||
conf: "input.follow_mouse_delay",
|
||||
targets: &[Target::Direct {
|
||||
component: COMP,
|
||||
version: 1,
|
||||
key: "focus_follows_cursor_delay",
|
||||
}],
|
||||
ty: Ty::U32,
|
||||
validate: Some(Range {
|
||||
min: 0.0,
|
||||
max: 5000.0,
|
||||
}),
|
||||
doc: "Delay in ms before focus follows the mouse",
|
||||
},
|
||||
// ---- theme -----------------------------------------------------------
|
||||
Entry {
|
||||
conf: "theme.mode",
|
||||
targets: &[Target::Direct {
|
||||
component: THEME_MODE,
|
||||
version: 1,
|
||||
key: "is_dark",
|
||||
}],
|
||||
ty: Ty::Mode,
|
||||
validate: None,
|
||||
doc: "`dark` or `light`",
|
||||
},
|
||||
Entry {
|
||||
conf: "theme.accent",
|
||||
targets: both_themes_direct!("accent"),
|
||||
ty: Ty::Rgb,
|
||||
validate: None,
|
||||
doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)",
|
||||
},
|
||||
Entry {
|
||||
conf: "theme.bg_color",
|
||||
targets: both_themes_direct!("bg_color"),
|
||||
ty: Ty::Rgba,
|
||||
validate: None,
|
||||
doc: "Background base colour",
|
||||
},
|
||||
Entry {
|
||||
conf: "theme.icon_theme",
|
||||
targets: &[Target::Direct {
|
||||
component: TK,
|
||||
version: 1,
|
||||
key: "icon_theme",
|
||||
}],
|
||||
ty: Ty::Str,
|
||||
validate: None,
|
||||
doc: "Icon theme name, e.g. Tela-circle-dracula",
|
||||
},
|
||||
];
|
||||
|
||||
/// Exact lookup by dotted conf path.
|
||||
pub fn lookup(conf: &str) -> Option<&'static Entry> {
|
||||
REGISTRY.iter().find(|e| e.conf == conf)
|
||||
}
|
||||
|
||||
/// Nearest known key by edit distance, for "did you mean" diagnostics.
|
||||
/// Only suggests when the candidate is close enough to be plausible.
|
||||
pub fn suggest(conf: &str) -> Option<&'static str> {
|
||||
let budget = match conf.len() {
|
||||
0..=4 => 1,
|
||||
5..=8 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
REGISTRY
|
||||
.iter()
|
||||
.map(|e| (edit_distance(conf, e.conf), e.conf))
|
||||
.filter(|(d, _)| *d <= budget)
|
||||
.min_by_key(|(d, _)| *d)
|
||||
.map(|(_, c)| c)
|
||||
}
|
||||
|
||||
/// Levenshtein distance, two-row variant.
|
||||
fn edit_distance(a: &str, b: &str) -> usize {
|
||||
let a: Vec<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
if a.is_empty() {
|
||||
return b.len();
|
||||
}
|
||||
if b.is_empty() {
|
||||
return a.len();
|
||||
}
|
||||
|
||||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||
let mut cur = vec![0usize; b.len() + 1];
|
||||
|
||||
for (i, ca) in a.iter().enumerate() {
|
||||
cur[0] = i + 1;
|
||||
for (j, cb) in b.iter().enumerate() {
|
||||
let cost = usize::from(ca != cb);
|
||||
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
|
||||
}
|
||||
std::mem::swap(&mut prev, &mut cur);
|
||||
}
|
||||
prev[b.len()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gaps_use_the_verified_tuple_order() {
|
||||
// ThemeBuilder.gaps is (outer, inner) — theme.rs:895. Getting this
|
||||
// backwards silently swaps the user's gaps, so pin it.
|
||||
let inner = lookup("general.gaps_in").unwrap();
|
||||
let outer = lookup("general.gaps_out").unwrap();
|
||||
|
||||
for t in inner.targets {
|
||||
match t {
|
||||
Target::Projected { path, .. } => assert_eq!(*path, &["1"]),
|
||||
other => panic!("gaps_in should project, got {other:?}"),
|
||||
}
|
||||
}
|
||||
for t in outer.targets {
|
||||
match t {
|
||||
Target::Projected { path, .. } => assert_eq!(*path, &["0"]),
|
||||
other => panic!("gaps_out should project, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_keys_fan_out_to_dark_and_light() {
|
||||
let e = lookup("general.gaps_in").unwrap();
|
||||
let comps: Vec<_> = e.targets.iter().map(|t| t.component()).collect();
|
||||
assert!(comps.contains(&"com.system76.CosmicTheme.Dark.Builder"));
|
||||
assert!(comps.contains(&"com.system76.CosmicTheme.Light.Builder"));
|
||||
assert_eq!(comps.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_keys_do_not_fan_out() {
|
||||
let e = lookup("general.autotile").unwrap();
|
||||
assert_eq!(e.targets.len(), 1);
|
||||
assert_eq!(e.targets[0].component(), "com.system76.CosmicComp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_split_targets_the_forks_own_comp_key() {
|
||||
// Unlike every other `general` key this one does not exist upstream --
|
||||
// it is a field this fork adds to `CosmicCompConfig`. If a rebase ever
|
||||
// drops that field the compositor silently ignores the key, so pin the
|
||||
// exact target here rather than trusting the generic registry checks.
|
||||
let e = lookup("general.preserve_split").unwrap();
|
||||
assert_eq!(e.targets.len(), 1);
|
||||
assert_eq!(e.targets[0].component(), "com.system76.CosmicComp");
|
||||
assert!(matches!(e.ty, Ty::Bool));
|
||||
}
|
||||
|
||||
/// `input.*` is an alias layer, so what matters is that it points at the
|
||||
/// same place COSMIC's own naming does. If a rebase renames either target
|
||||
/// key, one spelling would keep working and the other would go quietly
|
||||
/// dead; pinning them together here makes that a test failure instead.
|
||||
#[test]
|
||||
fn the_input_section_aliases_the_general_focus_keys() {
|
||||
for (hypr, cosmic) in [
|
||||
("input.follow_mouse", "general.focus_follows_cursor"),
|
||||
(
|
||||
"input.follow_mouse_delay",
|
||||
"general.focus_follows_cursor_delay",
|
||||
),
|
||||
] {
|
||||
let a = lookup(hypr).unwrap();
|
||||
let b = lookup(cosmic).unwrap();
|
||||
assert_eq!(a.targets, b.targets, "{hypr} and {cosmic} have drifted");
|
||||
}
|
||||
}
|
||||
|
||||
/// The alias is not a plain bool: Hyprland writes it as a number, and
|
||||
/// `Ty::FollowMouse` is what turns the accepted numbers into one.
|
||||
#[test]
|
||||
fn follow_mouse_uses_the_hyprland_numeric_type() {
|
||||
assert!(matches!(
|
||||
lookup("input.follow_mouse").unwrap().ty,
|
||||
Ty::FollowMouse
|
||||
));
|
||||
assert!(matches!(
|
||||
lookup("general.focus_follows_cursor").unwrap().ty,
|
||||
Ty::Bool
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_entry_has_at_least_one_target() {
|
||||
for e in REGISTRY {
|
||||
assert!(!e.targets.is_empty(), "`{}` has no targets", e.conf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conf_paths_are_unique() {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for e in REGISTRY {
|
||||
assert!(seen.insert(e.conf), "duplicate registry entry `{}`", e.conf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_entry_is_documented() {
|
||||
// `doc` generates cosmic.conf.default; an empty one would ship a blank
|
||||
// reference line.
|
||||
for e in REGISTRY {
|
||||
assert!(!e.doc.trim().is_empty(), "`{}` has no doc", e.conf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggests_near_misses() {
|
||||
assert_eq!(suggest("general.gaps_inn"), Some("general.gaps_in"));
|
||||
assert_eq!(suggest("general.autotil"), Some("general.autotile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_suggest_nonsense() {
|
||||
assert_eq!(suggest("completely.unrelated.nonsense.key"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
//! Filesystem watch: re-apply `cosmic.conf` on every edit.
|
||||
//!
|
||||
//! Two problems make this more than "call `notify` and re-run `main`'s
|
||||
//! pipeline":
|
||||
//!
|
||||
//! 1. **`source` fans out the watch set.** `parser::Item::Source` lets a
|
||||
//! config pull in other files (`parser.rs:66`), but `resolve` treats
|
||||
//! `Source` as inert (`resolve.rs:87`) — nothing upstream actually expands
|
||||
//! it yet, even though `resolve.rs:66` already assumes an "include
|
||||
//! expansion" pass ran first. This module is that pass: `merge_text`
|
||||
//! textually splices a sourced file's contents in place of its `source`
|
||||
//! line, the same way Hyprland treats `source` as literal inclusion. Doing
|
||||
//! it as text rather than AST-splicing means the merged string is one
|
||||
//! coherent document, so `Span`s (which are just line/col, with no file
|
||||
//! identity — `parser.rs:24`) stay correct for `render_diagnostic`
|
||||
//! regardless of which physical file a line came from. It also means the
|
||||
//! watch set has to be recomputed after every successful compile, since
|
||||
//! editing a `source` line can add or remove files from it.
|
||||
//!
|
||||
//! 2. **A bad edit must not kill the daemon or half-apply.** `Emitter::plan`
|
||||
//! already keeps `apply` transactional (`emit.rs:11-14`); this module's
|
||||
//! job is to keep that guarantee across an unbounded stream of edits by
|
||||
//! treating every compile failure as "log it and keep watching" rather
|
||||
//! than propagating it out of the loop.
|
||||
//!
|
||||
//! The event loop itself (`watch`) is intentionally thin. Everything with
|
||||
//! interesting logic — merging sources, debouncing — is a free function
|
||||
//! usable without a real inotify watcher, per the module's tests.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
use crate::emit::{EmitError, Emitter, Planned};
|
||||
use crate::parser::{self, Item, ParseError};
|
||||
use crate::render_diagnostic;
|
||||
use crate::resolve::{self, Diagnostic};
|
||||
|
||||
/// Editors commonly write a save as several syscalls (truncate, write,
|
||||
/// rename); this is long enough to collapse those into one recompile without
|
||||
/// making a real edit feel laggy.
|
||||
const DEBOUNCE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Everything that can go wrong compiling `config` (and whatever it sources)
|
||||
/// into a plan. Every variant renders a complete, human-readable report —
|
||||
/// `watch` just prints `Display` and moves on.
|
||||
#[derive(Debug)]
|
||||
pub enum CompileError {
|
||||
/// `config`, or something it `source`s, could not be read.
|
||||
Read { path: PathBuf, error: io::Error },
|
||||
/// A `source` chain refers back to a file already being expanded.
|
||||
/// Splicing it would recurse forever, so this is reported instead.
|
||||
Cycle { path: PathBuf },
|
||||
/// A single file failed to parse on its own, before merging — `source`
|
||||
/// and `error` are both that file's, so the line number is exact.
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
source: String,
|
||||
error: ParseError,
|
||||
},
|
||||
/// The merged document failed to resolve. `source` is the full merged
|
||||
/// text, so `diagnostics`' spans point at the right physical line no
|
||||
/// matter which file contributed it.
|
||||
Resolve {
|
||||
source: String,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
},
|
||||
/// Resolved cleanly but could not be turned into file contents.
|
||||
Emit(Vec<EmitError>),
|
||||
}
|
||||
|
||||
impl fmt::Display for CompileError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
CompileError::Read { path, error } => {
|
||||
writeln!(f, "error: cannot read {}: {error}", path.display())
|
||||
}
|
||||
CompileError::Cycle { path } => {
|
||||
writeln!(
|
||||
f,
|
||||
"error: `source` cycle detected while expanding {}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
CompileError::Parse {
|
||||
path,
|
||||
source,
|
||||
error,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"in {}:\n{}",
|
||||
path.display(),
|
||||
render_diagnostic(source, error.span, &error.message, None)
|
||||
)
|
||||
}
|
||||
CompileError::Resolve {
|
||||
source,
|
||||
diagnostics,
|
||||
} => {
|
||||
let mut out = String::new();
|
||||
for d in diagnostics {
|
||||
out.push_str(&render_diagnostic(
|
||||
source,
|
||||
d.span,
|
||||
&d.message,
|
||||
d.help.as_deref(),
|
||||
));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"error: {} problem(s) found; nothing was written\n",
|
||||
diagnostics.len()
|
||||
));
|
||||
write!(f, "{out}")
|
||||
}
|
||||
CompileError::Emit(errs) => {
|
||||
let mut out = String::new();
|
||||
for e in errs {
|
||||
out.push_str(&format!("error: {e}\n"));
|
||||
}
|
||||
out.push_str("error: nothing was written\n");
|
||||
write!(f, "{out}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CompileError {}
|
||||
|
||||
/// A completed compile: what to write, and what to watch.
|
||||
#[derive(Debug)]
|
||||
pub struct Compiled {
|
||||
pub planned: Vec<Planned>,
|
||||
/// Every file that contributed content, `config` first. This is exactly
|
||||
/// the set `watch` needs to be subscribed to for the *next* edit to be
|
||||
/// noticed, and it can change from one compile to the next as `source`
|
||||
/// lines are added, removed, or edited.
|
||||
pub sources: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Parse `config` — following `source` directives — resolve, and plan writes
|
||||
/// against `emitter`, without touching disk.
|
||||
///
|
||||
/// Pulled out of `watch` so the compile pipeline is unit-testable without a
|
||||
/// filesystem watcher: every test in this module drives `compile` directly.
|
||||
pub fn compile(config: &Path, emitter: &Emitter) -> Result<Compiled, CompileError> {
|
||||
let mut ancestors = Vec::new();
|
||||
let mut sources = Vec::new();
|
||||
let merged = merge_text(config, &mut ancestors, &mut sources)?;
|
||||
|
||||
let ast = parser::parse(&merged).map_err(|error| CompileError::Parse {
|
||||
path: config.to_path_buf(),
|
||||
source: merged.clone(),
|
||||
error,
|
||||
})?;
|
||||
|
||||
let resolved = resolve::resolve(&ast).map_err(|diagnostics| CompileError::Resolve {
|
||||
source: merged,
|
||||
diagnostics,
|
||||
})?;
|
||||
|
||||
let planned = emitter.plan(&resolved).map_err(CompileError::Emit)?;
|
||||
|
||||
Ok(Compiled { planned, sources })
|
||||
}
|
||||
|
||||
/// Read `path`, then replace every `source = <path>` line with the
|
||||
/// (recursively expanded) text of the sourced file, so the result is one
|
||||
/// document `parser::parse` can consume in a single pass — see the module
|
||||
/// doc for why textual splicing rather than AST splicing.
|
||||
///
|
||||
/// `ancestors` is the current inclusion chain (for cycle detection);
|
||||
/// `watched` accumulates every file visited, in the order first seen.
|
||||
fn merge_text(
|
||||
path: &Path,
|
||||
ancestors: &mut Vec<PathBuf>,
|
||||
watched: &mut Vec<PathBuf>,
|
||||
) -> Result<String, CompileError> {
|
||||
let key = path.to_path_buf();
|
||||
if ancestors.contains(&key) {
|
||||
return Err(CompileError::Cycle { path: key });
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(path).map_err(|error| CompileError::Read {
|
||||
path: key.clone(),
|
||||
error,
|
||||
})?;
|
||||
watched.push(key.clone());
|
||||
|
||||
// Parsing here (rather than scanning text for `source =` ourselves) means
|
||||
// we inherit the grammar's exact rules for comments and whitespace, so
|
||||
// the line we splice at is always the one the real parser would call a
|
||||
// `Source` item.
|
||||
let ast = parser::parse(&raw).map_err(|error| CompileError::Parse {
|
||||
path: key.clone(),
|
||||
source: raw.clone(),
|
||||
error,
|
||||
})?;
|
||||
|
||||
let mut targets = Vec::new();
|
||||
collect_sources(&ast.items, &mut targets);
|
||||
if targets.is_empty() {
|
||||
return Ok(raw);
|
||||
}
|
||||
|
||||
// Splicing changes line counts, so process bottom-up: replacing a later
|
||||
// line first leaves every earlier line number still valid.
|
||||
targets.sort_by_key(|t| std::cmp::Reverse(t.0));
|
||||
|
||||
ancestors.push(key);
|
||||
let mut lines: Vec<String> = raw.lines().map(String::from).collect();
|
||||
for (line_no, raw_path) in targets {
|
||||
let target_path = resolve_source_path(path, &raw_path);
|
||||
let included = merge_text(&target_path, ancestors, watched)?;
|
||||
lines.splice(line_no - 1..line_no, included.lines().map(String::from));
|
||||
}
|
||||
ancestors.pop();
|
||||
|
||||
let mut out = lines.join("\n");
|
||||
out.push('\n');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Depth-first walk collecting every `source` item's `(line, raw path)`.
|
||||
/// Sections are recursed into: a `source` nested inside `general { .. }`
|
||||
/// splices its contents into that section, matching Hyprland's textual
|
||||
/// `source` semantics rather than only supporting top-level includes.
|
||||
fn collect_sources(items: &[Item], out: &mut Vec<(usize, String)>) {
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Source { path } => out.push((path.span.line, path.value.clone())),
|
||||
Item::Section { items, .. } => collect_sources(items, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a `source` value the way a shell prompt would: `~/` against
|
||||
/// `$HOME`, everything else relative to the directory of the file doing the
|
||||
/// sourcing (not the process's cwd), so a config tree keeps working wherever
|
||||
/// it is checked out.
|
||||
fn resolve_source_path(containing_file: &Path, raw: &str) -> PathBuf {
|
||||
let expanded = if raw == "~" {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(raw))
|
||||
} else if let Some(rest) = raw.strip_prefix("~/") {
|
||||
match std::env::var_os("HOME") {
|
||||
Some(home) => PathBuf::from(home).join(rest),
|
||||
None => PathBuf::from(raw),
|
||||
}
|
||||
} else {
|
||||
PathBuf::from(raw)
|
||||
};
|
||||
|
||||
if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
containing_file
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(expanded)
|
||||
}
|
||||
}
|
||||
|
||||
/// Block for the first item on `rx`, then keep draining anything that
|
||||
/// arrives within `window` of the previous one. Returns `None` once the
|
||||
/// sender side has been dropped and nothing more will ever come.
|
||||
///
|
||||
/// This is the whole debounce policy, factored out of `watch`'s loop so it
|
||||
/// can be tested against a plain channel instead of real filesystem events —
|
||||
/// editors write a save as several syscalls, and without this a single save
|
||||
/// would trigger several redundant recompiles.
|
||||
fn collect_batch<T>(rx: &mpsc::Receiver<T>, window: Duration) -> Option<Vec<T>> {
|
||||
let first = rx.recv().ok()?;
|
||||
let mut batch = vec![first];
|
||||
while let Ok(next) = rx.recv_timeout(window) {
|
||||
batch.push(next);
|
||||
}
|
||||
Some(batch)
|
||||
}
|
||||
|
||||
/// Bring `watcher`'s subscriptions in line with `wanted`, diffing against
|
||||
/// `current` so files that stopped being sourced are actually unwatched
|
||||
/// (otherwise the watch set only ever grows).
|
||||
///
|
||||
/// Best-effort: a `watch`/`unwatch` failure (e.g. a sourced file that does
|
||||
/// not exist yet) is not fatal — the next successful compile will retry with
|
||||
/// whatever the config asks for at that point.
|
||||
fn sync_watches(
|
||||
watcher: &mut RecommendedWatcher,
|
||||
current: &mut HashSet<PathBuf>,
|
||||
wanted: &[PathBuf],
|
||||
) {
|
||||
let wanted: HashSet<PathBuf> = wanted.iter().cloned().collect();
|
||||
|
||||
for stale in current.difference(&wanted) {
|
||||
let _ = watcher.unwatch(stale);
|
||||
}
|
||||
for fresh in wanted.difference(current) {
|
||||
let _ = watcher.watch(fresh, RecursiveMode::NonRecursive);
|
||||
}
|
||||
|
||||
*current = wanted;
|
||||
}
|
||||
|
||||
/// Anything that stops the daemon outright. Deliberately small: a broken
|
||||
/// `cosmic.conf` is *not* one of these — see the module doc — so this is
|
||||
/// only the notify plumbing itself failing to start.
|
||||
#[derive(Debug)]
|
||||
pub enum WatchError {
|
||||
Notify(notify::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for WatchError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
WatchError::Notify(e) => write!(f, "watch error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WatchError {}
|
||||
|
||||
impl From<notify::Error> for WatchError {
|
||||
fn from(e: notify::Error) -> Self {
|
||||
WatchError::Notify(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch `config` — and everything it currently `source`s — reapplying on
|
||||
/// every change until the watcher itself fails to start or stops delivering
|
||||
/// events. A malformed edit is reported to stderr and waited past: see the
|
||||
/// module doc for why that, not propagating the error, is the contract here.
|
||||
pub fn watch(config: &Path, emitter: &Emitter) -> Result<(), WatchError> {
|
||||
let (tx, rx) = mpsc::channel::<notify::Result<Event>>();
|
||||
let mut watcher: RecommendedWatcher = notify::recommended_watcher(tx)?;
|
||||
let mut watched: HashSet<PathBuf> = HashSet::new();
|
||||
|
||||
// The last diagnostic printed, so an unchanged one is not printed again.
|
||||
// A single save arrives as several inotify events -- modify, then
|
||||
// close_write, sometimes a rename when the editor writes atomically -- and
|
||||
// they do not all land inside one debounce window, so a broken config
|
||||
// otherwise reports itself three or four times per keystroke-save. Cleared
|
||||
// on every successful compile, so the same error reappearing after a good
|
||||
// one is still news and still printed.
|
||||
let mut last_error: Option<String> = None;
|
||||
|
||||
// Compile once up front: the desktop should reflect the config the
|
||||
// moment the daemon starts, and this also tells us the initial watch
|
||||
// set. If it fails, fall back to watching just `config` — that is the
|
||||
// one file guaranteed to exist, and a later successful compile will
|
||||
// widen the watch set to whatever it actually sources.
|
||||
match compile(config, emitter) {
|
||||
Ok(compiled) => {
|
||||
if let Err(e) = emitter.apply(&compiled.planned) {
|
||||
eprintln!("{}", CompileError::Emit(vec![e]));
|
||||
}
|
||||
sync_watches(&mut watcher, &mut watched, &compiled.sources);
|
||||
}
|
||||
Err(e) => {
|
||||
let text = e.to_string();
|
||||
eprintln!("{text}");
|
||||
last_error = Some(text);
|
||||
sync_watches(
|
||||
&mut watcher,
|
||||
&mut watched,
|
||||
std::slice::from_ref(&config.to_path_buf()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let Some(batch) = collect_batch(&rx, DEBOUNCE) else {
|
||||
// The sender was dropped, which only happens if `watcher` itself
|
||||
// was torn down — nothing more will ever arrive.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for event in &batch {
|
||||
if let Err(e) = event {
|
||||
eprintln!("watch error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
match compile(config, emitter) {
|
||||
Ok(compiled) => {
|
||||
last_error = None;
|
||||
if let Err(e) = emitter.apply(&compiled.planned) {
|
||||
eprintln!("{}", CompileError::Emit(vec![e]));
|
||||
}
|
||||
sync_watches(&mut watcher, &mut watched, &compiled.sources);
|
||||
}
|
||||
Err(e) => {
|
||||
// Leave `watched` alone: the fix for a bad edit might land in
|
||||
// an already-sourced file, and dropping back to watching
|
||||
// only `config` would miss that.
|
||||
let text = e.to_string();
|
||||
if last_error.as_deref() != Some(text.as_str()) {
|
||||
eprintln!("{text}");
|
||||
last_error = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write(dir: &Path, name: &str, contents: &str) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, contents).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
// ---- compile ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn compile_with_no_source_directives_watches_just_the_config() {
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
let config = write(
|
||||
conf_dir.path(),
|
||||
"cosmic.conf",
|
||||
"general {\n autotile = true\n}\n",
|
||||
);
|
||||
|
||||
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||
|
||||
assert_eq!(compiled.sources, vec![config]);
|
||||
assert_eq!(compiled.planned.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_follows_a_source_directive_and_lists_it_as_a_watch_target() {
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
let included = write(
|
||||
conf_dir.path(),
|
||||
"extra.conf",
|
||||
"general {\n autotile = true\n}\n",
|
||||
);
|
||||
let config = write(conf_dir.path(), "cosmic.conf", "source = extra.conf\n");
|
||||
|
||||
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||
|
||||
assert_eq!(compiled.sources, vec![config, included]);
|
||||
assert_eq!(compiled.planned.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_expands_a_source_nested_inside_a_section() {
|
||||
// The sourced file's contents become part of the enclosing section,
|
||||
// the same way Hyprland's `source` is a literal text substitution.
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
write(conf_dir.path(), "gaps.conf", "gaps_in = 5\ngaps_out = 10\n");
|
||||
let config = write(
|
||||
conf_dir.path(),
|
||||
"cosmic.conf",
|
||||
"general {\n source = gaps.conf\n autotile = true\n}\n",
|
||||
);
|
||||
|
||||
let planned = compile(&config, &Emitter::with_root(root_dir.path()))
|
||||
.unwrap()
|
||||
.planned;
|
||||
let gaps = planned
|
||||
.iter()
|
||||
.find(|p| p.path.ends_with("gaps"))
|
||||
.expect("gaps planned");
|
||||
assert_eq!(gaps.contents, "(10, 5)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_resolves_relative_sources_against_the_including_files_directory() {
|
||||
// The including file lives in a subdirectory; `nested.conf` must be
|
||||
// found relative to it, not relative to the process's cwd.
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let sub = conf_dir.path().join("sub");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
write(&sub, "nested.conf", "general {\n autotile = true\n}\n");
|
||||
let config = write(&sub, "cosmic.conf", "source = nested.conf\n");
|
||||
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
let compiled = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||
assert_eq!(compiled.sources.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_expands_tilde_against_home() {
|
||||
let home = TempDir::new().unwrap();
|
||||
write(
|
||||
home.path(),
|
||||
"shared.conf",
|
||||
"general {\n autotile = true\n}\n",
|
||||
);
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let config = write(conf_dir.path(), "cosmic.conf", "source = ~/shared.conf\n");
|
||||
|
||||
let prev = std::env::var_os("HOME");
|
||||
std::env::set_var("HOME", home.path());
|
||||
let result = compile(&config, &Emitter::with_root(TempDir::new().unwrap().path()));
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
|
||||
let compiled = result.unwrap();
|
||||
assert!(compiled.sources.contains(&home.path().join("shared.conf")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_follows_a_chain_of_nested_sources() {
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
write(conf_dir.path(), "c.conf", "autotile = true\n");
|
||||
write(
|
||||
conf_dir.path(),
|
||||
"b.conf",
|
||||
"general {\n source = c.conf\n}\n",
|
||||
);
|
||||
let config = write(conf_dir.path(), "a.conf", "source = b.conf\n");
|
||||
|
||||
let compiled =
|
||||
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap();
|
||||
assert_eq!(compiled.sources.len(), 3);
|
||||
assert_eq!(compiled.planned.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_detects_a_source_cycle() {
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let a = conf_dir.path().join("a.conf");
|
||||
let b = conf_dir.path().join("b.conf");
|
||||
fs::write(&a, "source = b.conf\n").unwrap();
|
||||
fs::write(&b, "source = a.conf\n").unwrap();
|
||||
|
||||
let err = compile(&a, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||
assert!(matches!(err, CompileError::Cycle { .. }), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_reports_a_missing_source_file_without_panicking() {
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let config = write(
|
||||
conf_dir.path(),
|
||||
"cosmic.conf",
|
||||
"source = does-not-exist.conf\n",
|
||||
);
|
||||
|
||||
let err =
|
||||
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||
assert!(matches!(err, CompileError::Read { .. }), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_surfaces_a_syntax_error_in_a_sourced_file() {
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
write(conf_dir.path(), "broken.conf", "this is not valid\n");
|
||||
let config = write(conf_dir.path(), "cosmic.conf", "source = broken.conf\n");
|
||||
|
||||
let err =
|
||||
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||
match err {
|
||||
CompileError::Parse { path, .. } => {
|
||||
assert_eq!(path, conf_dir.path().join("broken.conf"))
|
||||
}
|
||||
other => panic!("expected Parse, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_diagnostic_line_number_points_at_the_merged_document_not_the_fragment() {
|
||||
// The offending line is line 1 of `bad.conf`, but after splicing it
|
||||
// sits at line 2 of the merged document — the diagnostic must report
|
||||
// the merged position so the caret lands on the right physical line.
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
write(conf_dir.path(), "bad.conf", "gaps_inn = 8\n");
|
||||
let config = write(
|
||||
conf_dir.path(),
|
||||
"cosmic.conf",
|
||||
"general {\n source = bad.conf\n}\n",
|
||||
);
|
||||
|
||||
let err =
|
||||
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||
match err {
|
||||
CompileError::Resolve { diagnostics, .. } => {
|
||||
assert_eq!(diagnostics[0].span.line, 2);
|
||||
}
|
||||
other => panic!("expected Resolve, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_surfaces_resolve_diagnostics_for_an_unknown_key() {
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let config = write(
|
||||
conf_dir.path(),
|
||||
"cosmic.conf",
|
||||
"general {\n gaps_inn = 8\n}\n",
|
||||
);
|
||||
|
||||
let err =
|
||||
compile(&config, &Emitter::with_root(TempDir::new().unwrap().path())).unwrap_err();
|
||||
assert!(matches!(err, CompileError::Resolve { .. }), "{err}");
|
||||
assert!(err.to_string().contains("unknown key"), "{err}");
|
||||
}
|
||||
|
||||
/// Mirrors `emit.rs`'s `plan_does_not_write`: `compile` only plans, so it
|
||||
/// must leave the cosmic-config tree untouched.
|
||||
#[test]
|
||||
fn compile_does_not_write_to_the_config_root() {
|
||||
let conf_dir = TempDir::new().unwrap();
|
||||
let config = write(
|
||||
conf_dir.path(),
|
||||
"cosmic.conf",
|
||||
"general {\n autotile = true\n}\n",
|
||||
);
|
||||
let root_dir = TempDir::new().unwrap();
|
||||
|
||||
let _ = compile(&config, &Emitter::with_root(root_dir.path())).unwrap();
|
||||
|
||||
assert!(
|
||||
fs::read_dir(root_dir.path()).unwrap().next().is_none(),
|
||||
"compile must leave the tree untouched"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- resolve_source_path -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_source_path_is_relative_to_the_including_file_not_the_cwd() {
|
||||
let including = Path::new("/somewhere/deep/cosmic.conf");
|
||||
assert_eq!(
|
||||
resolve_source_path(including, "extra.conf"),
|
||||
Path::new("/somewhere/deep/extra.conf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_source_path_leaves_absolute_paths_alone() {
|
||||
let including = Path::new("/somewhere/deep/cosmic.conf");
|
||||
assert_eq!(
|
||||
resolve_source_path(including, "/etc/other.conf"),
|
||||
Path::new("/etc/other.conf")
|
||||
);
|
||||
}
|
||||
|
||||
// Tilde expansion against `$HOME` is covered end-to-end by
|
||||
// `compile_expands_tilde_against_home` below rather than here too:
|
||||
// `std::env::set_var` mutates process-global state, and the default
|
||||
// test runner is multi-threaded, so two tests racing to set `HOME`
|
||||
// would be a real source of flakiness rather than a hypothetical one.
|
||||
|
||||
// ---- collect_batch (debounce) -------------------------------------
|
||||
//
|
||||
// These exercise the debounce policy directly against a plain channel,
|
||||
// with no filesystem or notify involvement at all, so they are fast and
|
||||
// cannot flake on OS-level event timing.
|
||||
|
||||
#[test]
|
||||
fn collect_batch_drains_everything_already_sent_before_it_was_called() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
for i in 0..5 {
|
||||
tx.send(i).unwrap();
|
||||
}
|
||||
let batch = collect_batch(&rx, Duration::from_millis(30)).unwrap();
|
||||
assert_eq!(batch, vec![0, 1, 2, 3, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_batch_returns_none_once_the_sender_is_dropped() {
|
||||
let (tx, rx) = mpsc::channel::<i32>();
|
||||
drop(tx);
|
||||
assert!(collect_batch(&rx, Duration::from_millis(30)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_batch_starts_a_fresh_batch_after_the_quiet_window_elapses() {
|
||||
use std::thread;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let window = Duration::from_millis(20);
|
||||
|
||||
tx.send(1).unwrap();
|
||||
let first = collect_batch(&rx, window).unwrap();
|
||||
assert_eq!(first, vec![1]);
|
||||
|
||||
// Send the second burst from another thread after the window has
|
||||
// safely elapsed (10x margin), so the main thread's blocking `recv`
|
||||
// in the next `collect_batch` call has something to wake it up.
|
||||
thread::spawn(move || {
|
||||
thread::sleep(window * 10);
|
||||
tx.send(2).unwrap();
|
||||
});
|
||||
let second = collect_batch(&rx, window).unwrap();
|
||||
assert_eq!(second, vec![2]);
|
||||
}
|
||||
|
||||
// ---- sync_watches ---------------------------------------------------
|
||||
//
|
||||
// Exercises the real notify watch/unwatch bookkeeping — but only ever
|
||||
// registers watches on files that already exist; no event is triggered
|
||||
// or waited for, so this cannot flake on inotify timing.
|
||||
|
||||
#[test]
|
||||
fn sync_watches_adds_and_then_removes_a_watch() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let a = write(dir.path(), "a.conf", "");
|
||||
let b = write(dir.path(), "b.conf", "");
|
||||
|
||||
let (tx, _rx) = mpsc::channel::<notify::Result<Event>>();
|
||||
let mut watcher = notify::recommended_watcher(tx).unwrap();
|
||||
let mut current = HashSet::new();
|
||||
|
||||
sync_watches(&mut watcher, &mut current, &[a.clone(), b.clone()]);
|
||||
assert_eq!(current, HashSet::from([a.clone(), b.clone()]));
|
||||
|
||||
// Dropping `b` from the wanted set must unwatch it, not just stop
|
||||
// tracking it, or the watch set would only ever grow.
|
||||
sync_watches(&mut watcher, &mut current, std::slice::from_ref(&a));
|
||||
assert_eq!(current, HashSet::from([a]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Hyprland `windowrule` lines -> COSMIC window rules.
|
||||
//!
|
||||
//! Hyprland's `windowrule` is a large surface: an action, a match, and around
|
||||
//! forty possible actions ranging from `float` to `bordercolor`. Exactly one of
|
||||
//! them is implemented here, `workspace`, because it is the one with a real
|
||||
//! COSMIC counterpart -- a window can be mapped onto a workspace other than the
|
||||
//! active one, which is what `windowrule = workspace 4, class:...` means.
|
||||
//!
|
||||
//! Everything else is refused with an explanation rather than accepted and
|
||||
//! dropped. A rule that parses and then does nothing is the worst outcome
|
||||
//! available: the config looks right, the window opens in the wrong place, and
|
||||
//! there is nothing to read that says why.
|
||||
//!
|
||||
//! The match half is `class:` and `title:`, both regular expressions, both
|
||||
//! compiled here so a broken one is a diagnostic against the line that wrote it
|
||||
//! rather than a warning in the compositor log nobody reads.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::parser::Span;
|
||||
|
||||
/// Where a matching window opens.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkspaceTarget {
|
||||
/// 1-based, as the user counts them.
|
||||
Index(u32),
|
||||
/// Matched against the workspace name, which is what a `workspace` line
|
||||
/// sets.
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// One `windowrule = ...` line.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WindowRuleDecl {
|
||||
/// Regular expression for the window's app id. Empty matches anything.
|
||||
pub class: String,
|
||||
/// Regular expression for the window's title. Empty matches anything.
|
||||
pub title: String,
|
||||
pub workspace: WorkspaceTarget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WindowRuleError {
|
||||
pub message: String,
|
||||
pub help: Option<String>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> WindowRuleError {
|
||||
WindowRuleError {
|
||||
message: message.into(),
|
||||
help: help.map(str::to_string),
|
||||
span,
|
||||
}
|
||||
}
|
||||
|
||||
/// The actions Hyprland has that this cannot do, and why saying so beats
|
||||
/// guessing. `float` and `tile` are called out separately because they are the
|
||||
/// next most likely thing to be reached for and COSMIC does have a mechanism --
|
||||
/// just not one cosmic.conf owns.
|
||||
const FLOAT_HELP: &str = "cosmic-comp decides floating from its tiling exceptions, which belong to \
|
||||
cosmic-settings (com.system76.CosmicSettings.WindowRules) rather than to \
|
||||
cosmic.conf. Add the application there and it will float on every \
|
||||
workspace.";
|
||||
|
||||
const ACTION_HELP: &str = "only `workspace` is supported. Hyprland's other rules -- float, size, \
|
||||
move, opacity, bordercolor and the rest -- have no COSMIC equivalent to \
|
||||
project onto.";
|
||||
|
||||
/// Matchers Hyprland has that depend on window state at match time. Ours runs
|
||||
/// once, when the window is mapped, so none of these can be answered.
|
||||
const MATCHER_HELP: &str = "rules are matched once, as the window opens, so only what the window \
|
||||
arrives with can be tested: class and title.";
|
||||
|
||||
/// Parse the body of a `windowrule` line.
|
||||
///
|
||||
/// `windowrule = workspace 4, class:^(vivaldi.*)$`
|
||||
///
|
||||
/// The first field is the action, the rest are matchers. `windowrulev2` is the
|
||||
/// same thing under an older name -- Hyprland merged v2's syntax into
|
||||
/// `windowrule` and kept the alias, and HyDE-era configs are full of it.
|
||||
pub fn parse_window_rule(value: &str, span: Span) -> Result<WindowRuleDecl, WindowRuleError> {
|
||||
let mut parts = value.split(',');
|
||||
|
||||
let action = parts
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
err(
|
||||
span,
|
||||
"a window rule needs an action",
|
||||
Some("for example: windowrule = workspace 4, class:^(vivaldi)$"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let workspace = parse_action(action, span)?;
|
||||
|
||||
let mut class = String::new();
|
||||
let mut title = String::new();
|
||||
let mut matched_on = false;
|
||||
|
||||
for raw in parts {
|
||||
let param = raw.trim();
|
||||
if param.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((key, arg)) = param.split_once(':') else {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("expected `key:value`, found `{param}`"),
|
||||
Some("known matchers: class, title"),
|
||||
));
|
||||
};
|
||||
let arg = arg.trim();
|
||||
// `initialClass`/`initialTitle` are accepted as spellings of the same
|
||||
// thing rather than as approximations of it: the match happens as the
|
||||
// window is mapped, so the title being tested *is* the initial one.
|
||||
match key.trim().to_ascii_lowercase().as_str() {
|
||||
"class" | "initialclass" => {
|
||||
check_regex(arg, "class", span)?;
|
||||
class = arg.to_string();
|
||||
matched_on = true;
|
||||
}
|
||||
"title" | "initialtitle" => {
|
||||
check_regex(arg, "title", span)?;
|
||||
title = arg.to_string();
|
||||
matched_on = true;
|
||||
}
|
||||
// Named rather than swept into the catch-all so the message can say
|
||||
// why a matcher Hyprland does have is not accepted here.
|
||||
"floating" | "fullscreen" | "pinned" | "focus" | "workspace" | "onworkspace"
|
||||
| "xwayland" | "tag" | "fullscreenstate" => {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("`{}:` cannot be matched on", key.trim()),
|
||||
Some(MATCHER_HELP),
|
||||
));
|
||||
}
|
||||
other => {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("unknown matcher `{other}`"),
|
||||
Some("known matchers: class, title"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !matched_on {
|
||||
return Err(err(
|
||||
span,
|
||||
"a window rule needs something to match on",
|
||||
Some(
|
||||
"without a class or a title the rule matches every window, which \
|
||||
would send the whole session to one workspace.",
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(WindowRuleDecl {
|
||||
class,
|
||||
title,
|
||||
workspace,
|
||||
})
|
||||
}
|
||||
|
||||
/// `workspace 4`, `workspace name:web`, either with a trailing `silent`.
|
||||
fn parse_action(action: &str, span: Span) -> Result<WorkspaceTarget, WindowRuleError> {
|
||||
let (verb, rest) = match action.split_once(char::is_whitespace) {
|
||||
Some((verb, rest)) => (verb, rest.trim()),
|
||||
None => (action, ""),
|
||||
};
|
||||
if !verb.eq_ignore_ascii_case("workspace") {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("unsupported window rule `{verb}`"),
|
||||
Some(
|
||||
if verb.eq_ignore_ascii_case("float") || verb.eq_ignore_ascii_case("tile") {
|
||||
FLOAT_HELP
|
||||
} else {
|
||||
ACTION_HELP
|
||||
},
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Hyprland's `silent` means "put it there without switching to it". That is
|
||||
// unconditionally what happens here -- a rule places its window and leaves
|
||||
// the focus alone -- so the word is accepted as a description of the
|
||||
// behaviour rather than ignored as a request that went unheard.
|
||||
//
|
||||
// Stripped from the end rather than parsed as one word among several,
|
||||
// because a workspace name may contain spaces: `workspace = 2, name:web and
|
||||
// mail` is a legal declaration, so `workspace name:web and mail` has to be a
|
||||
// legal rule.
|
||||
let target = rest
|
||||
.rsplit_once(char::is_whitespace)
|
||||
.filter(|(_, last)| last.eq_ignore_ascii_case("silent"))
|
||||
.map_or(rest, |(head, _)| head.trim_end());
|
||||
|
||||
if target.is_empty() {
|
||||
return Err(err(
|
||||
span,
|
||||
"`workspace` needs a workspace to send the window to",
|
||||
Some("for example: workspace 4, or workspace name:web"),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(name) = target.strip_prefix("name:") {
|
||||
if name.is_empty() {
|
||||
return Err(err(span, "`name:` needs a workspace name", None));
|
||||
}
|
||||
return Ok(WorkspaceTarget::Name(name.to_string()));
|
||||
}
|
||||
|
||||
// Digits checked before parsing, not after: `u32::from_str` accepts a
|
||||
// leading `+`, so `workspace +1` -- Hyprland's "one to the right" -- would
|
||||
// otherwise parse as the absolute workspace 1 and send the window somewhere
|
||||
// the rule never asked for.
|
||||
if target.bytes().all(|b| b.is_ascii_digit()) {
|
||||
match target.parse::<u32>() {
|
||||
Ok(0) => return Err(err(span, "workspaces are numbered from 1", None)),
|
||||
Ok(index) => return Ok(WorkspaceTarget::Index(index)),
|
||||
// Only reachable by overflow, which the message below covers.
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else Hyprland accepts here is relative to where you are --
|
||||
// `+1`, `previous`, `empty`, `special` -- and a rule fires when a window
|
||||
// opens, so "the next workspace" would mean a different one every time.
|
||||
Err(err(
|
||||
span,
|
||||
format!("cannot send a window to `{target}`"),
|
||||
Some(
|
||||
"a rule names one fixed workspace: a number, or `name:` and the name \
|
||||
from a `workspace` line, optionally followed by `silent`. Relative \
|
||||
and special workspaces have no COSMIC equivalent.",
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn check_regex(pattern: &str, field: &str, span: Span) -> Result<(), WindowRuleError> {
|
||||
if pattern.is_empty() {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("`{field}:` needs a value"),
|
||||
Some("an empty expression matches every window; leave the matcher out instead."),
|
||||
));
|
||||
}
|
||||
regex::Regex::new(pattern).map_err(|e| {
|
||||
// The crate's own message is multi-line and already points at the
|
||||
// offending character, which is more useful than anything paraphrased.
|
||||
err(
|
||||
span,
|
||||
format!("`{field}:` is not a valid expression"),
|
||||
Some(&e.to_string().replace('\n', " ")),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ron_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the declarations as the RON `Vec<WindowRule>` cosmic-comp stores in
|
||||
/// `window_rules`.
|
||||
///
|
||||
/// Order is preserved because it is meaningful: the compositor takes the first
|
||||
/// rule that matches, and a file reads top to bottom, so the earlier line is
|
||||
/// the one a person expects to win.
|
||||
pub fn render(decls: &[WindowRuleDecl]) -> String {
|
||||
let mut out = String::from("[\n");
|
||||
for decl in decls {
|
||||
let workspace = match &decl.workspace {
|
||||
WorkspaceTarget::Index(n) => format!("Index({n})"),
|
||||
WorkspaceTarget::Name(name) => format!("Name({})", ron_string(name)),
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" (app_id: {}, title: {}, workspace: {workspace}),",
|
||||
ron_string(&decl.class),
|
||||
ron_string(&decl.title),
|
||||
);
|
||||
}
|
||||
out.push_str("]\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn span() -> Span {
|
||||
Span {
|
||||
line: 1,
|
||||
col: 1,
|
||||
len: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(s: &str) -> WindowRuleDecl {
|
||||
parse_window_rule(s, span()).expect(s)
|
||||
}
|
||||
|
||||
fn fail(s: &str) -> WindowRuleError {
|
||||
parse_window_rule(s, span()).expect_err(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hyprland_form_parses() {
|
||||
let r = ok("workspace 4, class:^(vivaldi.*)$");
|
||||
assert_eq!(r.workspace, WorkspaceTarget::Index(4));
|
||||
assert_eq!(r.class, "^(vivaldi.*)$");
|
||||
assert_eq!(r.title, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_workspace_is_carried_through() {
|
||||
assert_eq!(
|
||||
ok("workspace name:web, class:vivaldi").workspace,
|
||||
WorkspaceTarget::Name("web".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_and_title_can_both_be_given() {
|
||||
let r = ok("workspace 2, class:^(firefox)$, title:.*Mail.*");
|
||||
assert_eq!(r.class, "^(firefox)$");
|
||||
assert_eq!(r.title, ".*Mail.*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_alone_is_enough_to_match_on() {
|
||||
let r = ok("workspace 2, title:.*Mail.*");
|
||||
assert_eq!(r.class, "", "an empty class matches every app id");
|
||||
assert_eq!(r.title, ".*Mail.*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_spellings_are_the_same_matchers() {
|
||||
let r = ok("workspace 1, initialClass:foo, initialTitle:bar");
|
||||
assert_eq!(r.class, "foo");
|
||||
assert_eq!(r.title, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_is_accepted_because_it_describes_what_happens() {
|
||||
assert_eq!(
|
||||
ok("workspace 3 silent, class:foo").workspace,
|
||||
WorkspaceTarget::Index(3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_does_not_matter_for_keywords() {
|
||||
assert_eq!(
|
||||
ok("Workspace 3 SILENT, CLASS:foo").workspace,
|
||||
WorkspaceTarget::Index(3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_around_everything_is_tolerated() {
|
||||
let r = ok(" workspace 4 , class : ^foo$ ");
|
||||
assert_eq!(r.workspace, WorkspaceTarget::Index(4));
|
||||
assert_eq!(r.class, "^foo$");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_with_nothing_to_match_on_is_refused() {
|
||||
let e = fail("workspace 4");
|
||||
assert!(e.message.contains("something to match on"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsupported_action_says_which_one_is_supported() {
|
||||
let e = fail("size 100 100, class:foo");
|
||||
assert!(e.message.contains("unsupported window rule"), "{e:?}");
|
||||
assert!(e.help.unwrap().contains("only `workspace`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_points_at_the_tiling_exceptions_instead() {
|
||||
let e = fail("float, class:foo");
|
||||
assert!(
|
||||
e.help.as_deref().unwrap_or_default().contains("cosmic-settings"),
|
||||
"{e:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_matcher_explains_that_matching_happens_once() {
|
||||
let e = fail("workspace 4, class:foo, floating:1");
|
||||
assert!(e.message.contains("cannot be matched on"), "{e:?}");
|
||||
assert!(e.help.unwrap().contains("as the window opens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_matcher_lists_the_known_ones() {
|
||||
let e = fail("workspace 4, klass:foo");
|
||||
assert!(e.message.contains("unknown matcher"), "{e:?}");
|
||||
}
|
||||
|
||||
/// `u32::from_str` accepts a leading sign, so `+1` would silently become
|
||||
/// the absolute workspace 1 if the digits were not checked first.
|
||||
#[test]
|
||||
fn a_relative_workspace_is_refused_with_a_reason() {
|
||||
for target in ["+1", "-1", "previous", "empty", "e+1"] {
|
||||
let e = fail(&format!("workspace {target}, class:foo"));
|
||||
assert!(
|
||||
e.message.contains("cannot send a window to"),
|
||||
"{target}: {}",
|
||||
e.message
|
||||
);
|
||||
assert!(
|
||||
e.help.as_deref().unwrap_or_default().contains("one fixed workspace"),
|
||||
"{target}: {:?}",
|
||||
e.help
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_special_workspace_is_refused() {
|
||||
assert!(fail("workspace special:magic, class:foo")
|
||||
.message
|
||||
.contains("cannot send a window to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_zero_is_refused() {
|
||||
assert!(fail("workspace 0, class:foo")
|
||||
.message
|
||||
.contains("numbered from 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_expression_is_caught_here_not_in_the_compositor() {
|
||||
let e = fail("workspace 4, class:^(unclosed");
|
||||
assert!(e.message.contains("not a valid expression"), "{e:?}");
|
||||
assert!(e.help.is_some(), "the regex crate's own message is passed on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_matcher_is_refused_rather_than_matching_everything() {
|
||||
let e = fail("workspace 4, class:");
|
||||
assert!(e.message.contains("needs a value"), "{e:?}");
|
||||
}
|
||||
|
||||
/// `silent` is stripped off the end, so anything else trailing an index is
|
||||
/// part of the target and fails as one rather than being quietly dropped.
|
||||
#[test]
|
||||
fn a_modifier_that_is_not_silent_is_refused() {
|
||||
let e = fail("workspace 4 loud, class:foo");
|
||||
assert!(e.message.contains("cannot send a window to `4 loud`"), "{e:?}");
|
||||
}
|
||||
|
||||
/// A `workspace` line accepts a name with spaces in it, so a rule aiming at
|
||||
/// that workspace has to as well.
|
||||
#[test]
|
||||
fn a_workspace_name_may_contain_spaces() {
|
||||
assert_eq!(
|
||||
ok("workspace name:web and mail, class:foo").workspace,
|
||||
WorkspaceTarget::Name("web and mail".into())
|
||||
);
|
||||
assert_eq!(
|
||||
ok("workspace name:web and mail silent, class:foo").workspace,
|
||||
WorkspaceTarget::Name("web and mail".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_with_nothing_after_it_says_so() {
|
||||
let e = fail("workspace, class:foo");
|
||||
assert!(e.message.contains("needs a workspace"), "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_matches_the_ron_shape_cosmic_comp_reads() {
|
||||
let out = render(&[
|
||||
WindowRuleDecl {
|
||||
class: "^(vivaldi)$".into(),
|
||||
title: String::new(),
|
||||
workspace: WorkspaceTarget::Name("web".into()),
|
||||
},
|
||||
WindowRuleDecl {
|
||||
class: "^(kitty)$".into(),
|
||||
title: String::new(),
|
||||
workspace: WorkspaceTarget::Index(1),
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
out,
|
||||
concat!(
|
||||
"[\n",
|
||||
" (app_id: \"^(vivaldi)$\", title: \"\", workspace: Name(\"web\")),\n",
|
||||
" (app_id: \"^(kitty)$\", title: \"\", workspace: Index(1)),\n",
|
||||
"]\n",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_renders_as_an_empty_list() {
|
||||
assert_eq!(render(&[]), "[\n]\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_in_an_expression_cannot_break_out_of_the_ron() {
|
||||
let out = render(&[WindowRuleDecl {
|
||||
class: r#"^(say "hi")$"#.into(),
|
||||
title: String::new(),
|
||||
workspace: WorkspaceTarget::Index(1),
|
||||
}]);
|
||||
assert!(out.contains(r#"\"hi\""#), "{out}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Hyprland `workspace` lines -> COSMIC pinned workspaces.
|
||||
//!
|
||||
//! COSMIC's workspaces are dynamic and unconditionally so: `ensure_last_empty`
|
||||
//! keeps exactly one trailing empty workspace and garbage-collects every other
|
||||
//! empty one, and there is no setting anywhere that turns that off. A Hyprland
|
||||
//! user expects the opposite -- a fixed set of workspaces that exist whether or
|
||||
//! not anything is on them, so that "workspace 4 is the browser" stays true
|
||||
//! across a reboot.
|
||||
//!
|
||||
//! The primitive that bridges the two already exists in the compositor.
|
||||
//! `Workspace::can_auto_remove` is `is_empty() && !has_activation_token() &&
|
||||
//! !pinned`, so a pinned workspace survives being emptied, and
|
||||
//! `CosmicCompConfig::pinned_workspaces` is a persisted key that
|
||||
//! `Workspaces::add_output` drains into the first output that appears. Nothing
|
||||
//! in cosmic-comp needs to change: declaring workspaces here is enough.
|
||||
//!
|
||||
//! Three consequences of that restore path shape this module:
|
||||
//!
|
||||
//! 1. **Restore is positional.** `PinnedWorkspace` has no index field -- the
|
||||
//! order of the Vec becomes the order of the workspaces. So `workspace = 4`
|
||||
//! cannot emit one entry; it has to emit four, with 1..3 unnamed, or the
|
||||
//! declared workspace would land at index 1.
|
||||
//! 2. **The trailing dynamic workspace is kept.** Pinned workspaces are pushed
|
||||
//! into an empty `WorkspaceSet`, and `ensure_last_empty` then appends the
|
||||
//! usual empty one. Declaring four leaves you on 1-4 with a fifth appearing
|
||||
//! when you use it, which is Hyprland's behaviour rather than a compromise.
|
||||
//! 3. **It lands at the next login, not on apply.** `Workspaces::new` reads the
|
||||
//! key once when the compositor starts and there is no reload path for it,
|
||||
//! while `hyprcosmic-conf watch` is started from the autostart file *after*
|
||||
//! COSMIC's own components. So an edit is written immediately and takes
|
||||
//! effect the next time the session starts. Every other key in cosmic.conf
|
||||
//! is live, so this one is worth saying out loud.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::parser::Span;
|
||||
|
||||
/// One `workspace = ...` line.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceDecl {
|
||||
/// 1-based, as written. Workspaces below this one are materialised too.
|
||||
pub index: u32,
|
||||
/// Shown by anything reading ext-workspace, waybar included. `None` leaves
|
||||
/// COSMIC to label it by number.
|
||||
pub name: Option<String>,
|
||||
/// Per-workspace tiling. `None` inherits the session default.
|
||||
pub tiling: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceError {
|
||||
pub message: String,
|
||||
pub help: Option<String>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
fn err(span: Span, message: impl Into<String>, help: Option<&str>) -> WorkspaceError {
|
||||
WorkspaceError {
|
||||
message: message.into(),
|
||||
help: help.map(str::to_string),
|
||||
span,
|
||||
}
|
||||
}
|
||||
|
||||
/// Declaring index N materialises N workspaces, so a typo like `workspace = 100`
|
||||
/// would silently produce a hundred of them. Nobody drives a hundred
|
||||
/// workspaces; the cap turns a fat-finger into a diagnostic.
|
||||
const MAX_INDEX: u32 = 32;
|
||||
|
||||
/// Why `monitor:` is refused rather than accepted and ignored.
|
||||
///
|
||||
/// A `PinnedWorkspace` names its output through `OutputMatch { name, edid }`,
|
||||
/// and cosmic-comp's `output_matches` compares the EDID *first*: a match with
|
||||
/// `edid: None` is rejected outright against any output that reports one, and
|
||||
/// only falls through to the name when neither side has an EDID. Every real
|
||||
/// panel reports one, so a name-only match would work on a VM and nowhere else.
|
||||
///
|
||||
/// cosmic.conf cannot supply the EDID -- it is a manufacturer triple, product
|
||||
/// id, serial and manufacture date read off the wire by the DRM backend, not
|
||||
/// something a user can write down. Accepting `monitor:` would give a parameter
|
||||
/// that silently does nothing on the hardware people actually have.
|
||||
const MONITOR_HELP: &str = "COSMIC matches a workspace to a monitor by EDID rather than by name, \
|
||||
and cosmic.conf has no way to spell an EDID. Pinned workspaces are created \
|
||||
on the first output the session sees; move them with the workspace \
|
||||
shortcuts once they exist.";
|
||||
|
||||
/// Parse the body of a `workspace` line.
|
||||
///
|
||||
/// `workspace = 4, name:web, tiling:true`
|
||||
///
|
||||
/// The index comes first and is required. Hyprland also allows a leading
|
||||
/// `name:` with no index, for its special workspaces; COSMIC has no equivalent
|
||||
/// and the restore is positional besides, so that form is rejected with an
|
||||
/// explanation rather than guessed at.
|
||||
pub fn parse_workspace(value: &str, span: Span) -> Result<WorkspaceDecl, WorkspaceError> {
|
||||
let mut parts = value.split(',');
|
||||
|
||||
let head = parts
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
err(
|
||||
span,
|
||||
"a workspace needs an index",
|
||||
Some("for example: workspace = 4, name:web"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let index = head.parse::<u32>().map_err(|_| {
|
||||
if head.contains(':') {
|
||||
err(
|
||||
span,
|
||||
format!("a workspace starts with its index, found `{head}`"),
|
||||
Some(
|
||||
"COSMIC restores pinned workspaces by position, so every one \
|
||||
needs a number. Write `workspace = 4, name:web` rather than \
|
||||
`workspace = name:web`.",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
err(
|
||||
span,
|
||||
format!("expected a workspace index, found `{head}`"),
|
||||
Some("for example: workspace = 4, name:web"),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
|
||||
if index == 0 || index > MAX_INDEX {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("workspace index {index} is outside 1..={MAX_INDEX}"),
|
||||
Some(
|
||||
"workspaces are numbered from 1, and declaring one materialises \
|
||||
every workspace below it, so the highest index is capped.",
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let mut decl = WorkspaceDecl {
|
||||
index,
|
||||
name: None,
|
||||
tiling: None,
|
||||
};
|
||||
|
||||
for raw in parts {
|
||||
let param = raw.trim();
|
||||
if param.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((key, arg)) = param.split_once(':') else {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("expected `key:value`, found `{param}`"),
|
||||
Some("known parameters: name, tiling"),
|
||||
));
|
||||
};
|
||||
let arg = arg.trim();
|
||||
match key.trim().to_ascii_lowercase().as_str() {
|
||||
"name" => {
|
||||
if arg.is_empty() {
|
||||
return Err(err(span, "`name:` needs a value", None));
|
||||
}
|
||||
decl.name = Some(arg.to_string());
|
||||
}
|
||||
// Named rather than swept into the catch-all so the message can say
|
||||
// why a parameter Hyprland does have is not accepted here.
|
||||
"monitor" | "output" => {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("`{}:` has no COSMIC equivalent", key.trim()),
|
||||
Some(MONITOR_HELP),
|
||||
));
|
||||
}
|
||||
"tiling" => {
|
||||
decl.tiling = Some(match arg.to_ascii_lowercase().as_str() {
|
||||
"true" | "yes" | "on" | "1" => true,
|
||||
"false" | "no" | "off" | "0" => false,
|
||||
other => {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("`tiling:` expects a boolean, found `{other}`"),
|
||||
None,
|
||||
))
|
||||
}
|
||||
});
|
||||
}
|
||||
other => {
|
||||
return Err(err(
|
||||
span,
|
||||
format!("unknown workspace parameter `{other}`"),
|
||||
Some("known parameters: name, tiling"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(decl)
|
||||
}
|
||||
|
||||
fn ron_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// FNV-1a, written out rather than reached for.
|
||||
///
|
||||
/// `DefaultHasher` is explicitly documented as not stable across Rust releases,
|
||||
/// which would make workspace ids change under the user when the toolchain
|
||||
/// moves -- and the id is what ties a window's saved workspace to the workspace
|
||||
/// it reappears on. Twelve lines of FNV is cheaper than that class of bug.
|
||||
fn fnv1a(s: &str) -> u32 {
|
||||
let mut h: u32 = 0x811c9dc5;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u32;
|
||||
h = h.wrapping_mul(0x01000193);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// A stable id for the workspace at `index`.
|
||||
///
|
||||
/// cosmic-comp's own `random_workspace_id` is `format!("{:x}", rand(0..2<<24))`,
|
||||
/// which is at most seven hex digits. Forcing the high nibble on here makes ours
|
||||
/// exactly eight, so a generated id and a compositor-generated one cannot
|
||||
/// collide by construction rather than by being unlikely to.
|
||||
fn workspace_id(index: u32) -> String {
|
||||
let h = fnv1a(&format!("hyprcosmic:workspace:{index}"));
|
||||
format!("{:08x}", 0x1000_0000 | (h & 0x0fff_ffff))
|
||||
}
|
||||
|
||||
/// Render the declarations as the RON `Vec<PinnedWorkspace>` cosmic-comp stores
|
||||
/// in `pinned_workspaces`.
|
||||
///
|
||||
/// `default_tiling` is the session-wide `general:autotile`, used for any
|
||||
/// workspace that did not say. Without it, declaring workspaces would silently
|
||||
/// turn tiling off on all of them for a user who had asked for it globally.
|
||||
pub fn render(decls: &[WorkspaceDecl], default_tiling: bool) -> String {
|
||||
let highest = decls.iter().map(|d| d.index).max().unwrap_or(0);
|
||||
|
||||
let mut out = String::from("[\n");
|
||||
for index in 1..=highest {
|
||||
// Gaps are filled rather than skipped: the restore is positional, so an
|
||||
// absent workspace 3 would put the declared workspace 4 at position 3.
|
||||
let decl = decls.iter().find(|d| d.index == index);
|
||||
|
||||
let tiling = decl.and_then(|d| d.tiling).unwrap_or(default_tiling);
|
||||
let name = match decl.and_then(|d| d.name.as_deref()) {
|
||||
Some(n) => format!("Some({})", ron_string(n)),
|
||||
None => "None".to_string(),
|
||||
};
|
||||
|
||||
// An empty `OutputMatch` is "no preference", not a guess. It is the
|
||||
// front of the workspace's `output_stack`, which `prefers_output`
|
||||
// consults when an output appears; a name nothing can match leaves the
|
||||
// workspace where `add_output` created it. See MONITOR_HELP.
|
||||
let _ = writeln!(
|
||||
out,
|
||||
r#" (output: (name: "", edid: None), tiling_enabled: {tiling}, id: Some({}), name: {name}),"#,
|
||||
ron_string(&workspace_id(index)),
|
||||
);
|
||||
}
|
||||
out.push_str("]\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const S: Span = Span {
|
||||
line: 1,
|
||||
col: 1,
|
||||
len: 1,
|
||||
};
|
||||
|
||||
fn ws(v: &str) -> WorkspaceDecl {
|
||||
parse_workspace(v, S).expect("should parse")
|
||||
}
|
||||
|
||||
fn bad(v: &str) -> WorkspaceError {
|
||||
parse_workspace(v, S).expect_err("should not parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortest_useful_declaration_is_a_bare_index() {
|
||||
assert_eq!(
|
||||
ws("4"),
|
||||
WorkspaceDecl {
|
||||
index: 4,
|
||||
name: None,
|
||||
tiling: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_parameter_together() {
|
||||
assert_eq!(
|
||||
ws("4, name:web, tiling:true"),
|
||||
WorkspaceDecl {
|
||||
index: 4,
|
||||
name: Some("web".into()),
|
||||
tiling: Some(true),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_are_order_independent_and_tolerate_spacing() {
|
||||
assert_eq!(ws("2,tiling:off,name:mail"), ws("2, name:mail, tiling:off"));
|
||||
}
|
||||
|
||||
/// Hyprland's `monitor:` is the parameter a user is most likely to reach
|
||||
/// for, and the one COSMIC cannot honour. Accepting and ignoring it would
|
||||
/// be the worst of the three options, so it must fail and say why.
|
||||
#[test]
|
||||
fn monitor_is_refused_with_the_reason_rather_than_silently_ignored() {
|
||||
for spelling in ["monitor", "output"] {
|
||||
let e = bad(&format!("1, {spelling}:eDP-1"));
|
||||
assert!(
|
||||
e.message.contains("no COSMIC equivalent"),
|
||||
"{spelling}: {}",
|
||||
e.message
|
||||
);
|
||||
assert!(
|
||||
e.help.as_deref().unwrap_or_default().contains("EDID"),
|
||||
"{spelling}: {:?}",
|
||||
e.help
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_may_contain_spaces() {
|
||||
assert_eq!(ws("1, name:web and mail").name.as_deref(), Some("web and mail"));
|
||||
}
|
||||
|
||||
/// Hyprland's named-workspace form has no COSMIC equivalent, and the failure
|
||||
/// if it were guessed at would be workspaces in the wrong order.
|
||||
#[test]
|
||||
fn the_hyprland_name_first_form_is_refused_with_the_reason() {
|
||||
let e = bad("name:web, tiling:true");
|
||||
assert!(e.message.contains("starts with its index"), "{}", e.message);
|
||||
assert!(
|
||||
e.help.as_deref().unwrap_or_default().contains("by position"),
|
||||
"{:?}",
|
||||
e.help
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_zero_does_not_exist() {
|
||||
let e = bad("0");
|
||||
assert!(e.message.contains("outside 1..="), "{}", e.message);
|
||||
}
|
||||
|
||||
/// The cap is the whole reason this check exists: without it the typo below
|
||||
/// creates a thousand workspaces rather than reporting anything.
|
||||
#[test]
|
||||
fn an_absurd_index_is_a_diagnostic_not_a_thousand_workspaces() {
|
||||
let e = bad("1000");
|
||||
assert!(e.message.contains("outside 1..=32"), "{}", e.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_parameter_lists_the_known_ones() {
|
||||
let e = bad("1, gapsin:0");
|
||||
assert!(e.message.contains("gapsin"), "{}", e.message);
|
||||
assert!(
|
||||
e.help.as_deref().unwrap_or_default().contains("name, tiling"),
|
||||
"{:?}",
|
||||
e.help
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parameter_without_a_colon_is_rejected() {
|
||||
let e = bad("1, web");
|
||||
assert!(e.message.contains("key:value"), "{}", e.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiling_takes_the_same_boolean_spellings_as_the_rest_of_the_file() {
|
||||
for v in ["true", "yes", "on", "1"] {
|
||||
assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(true), "{v}");
|
||||
}
|
||||
for v in ["false", "no", "off", "0"] {
|
||||
assert_eq!(ws(&format!("1, tiling:{v}")).tiling, Some(false), "{v}");
|
||||
}
|
||||
assert!(bad("1, tiling:sometimes").message.contains("boolean"));
|
||||
}
|
||||
|
||||
/// The property the positional restore turns on: declaring only 4 must still
|
||||
/// emit 1, 2 and 3, or the browser workspace comes back as workspace 1.
|
||||
#[test]
|
||||
fn declaring_one_high_index_materialises_every_workspace_below_it() {
|
||||
let ron = render(&[ws("4, name:web")], false);
|
||||
assert_eq!(ron.lines().filter(|l| l.contains("output:")).count(), 4, "{ron}");
|
||||
|
||||
let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect();
|
||||
assert!(lines[0].contains("name: None"), "{}", lines[0]);
|
||||
assert!(lines[1].contains("name: None"), "{}", lines[1]);
|
||||
assert!(lines[2].contains("name: None"), "{}", lines[2]);
|
||||
assert!(lines[3].contains(r#"name: Some("web")"#), "{}", lines[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_between_declarations_are_filled_in_order() {
|
||||
let ron = render(&[ws("3, name:code"), ws("1, name:term")], false);
|
||||
let lines: Vec<&str> = ron.lines().filter(|l| l.contains("output:")).collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines[0].contains(r#"name: Some("term")"#), "{}", lines[0]);
|
||||
assert!(lines[1].contains("name: None"), "{}", lines[1]);
|
||||
assert!(lines[2].contains(r#"name: Some("code")"#), "{}", lines[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_declarations_render_as_an_empty_list() {
|
||||
assert_eq!(render(&[], false), "[\n]\n");
|
||||
}
|
||||
|
||||
/// A workspace that did not mention tiling must not quietly contradict
|
||||
/// `general:autotile`.
|
||||
#[test]
|
||||
fn unset_tiling_follows_the_session_default_both_ways() {
|
||||
assert!(render(&[ws("1")], true).contains("tiling_enabled: true"));
|
||||
assert!(render(&[ws("1")], false).contains("tiling_enabled: false"));
|
||||
// ...and an explicit value still wins over it.
|
||||
assert!(render(&[ws("1, tiling:false")], true).contains("tiling_enabled: false"));
|
||||
}
|
||||
|
||||
/// Not a placeholder: an unmatchable `OutputMatch` is how a workspace says
|
||||
/// it has no output preference, which is the only thing cosmic.conf can
|
||||
/// truthfully express. See MONITOR_HELP.
|
||||
#[test]
|
||||
fn the_output_match_is_always_empty() {
|
||||
assert!(render(&[ws("1")], false).contains(r#"output: (name: "", edid: None)"#));
|
||||
}
|
||||
|
||||
/// Ids have to survive a re-apply, or every `hyprcosmic-conf apply` would hand
|
||||
/// the same workspaces new identities.
|
||||
#[test]
|
||||
fn ids_are_stable_across_runs_and_unique_per_index() {
|
||||
assert_eq!(workspace_id(4), workspace_id(4));
|
||||
let ids: BTreeSet<String> = (1..=MAX_INDEX).map(workspace_id).collect();
|
||||
assert_eq!(ids.len(), MAX_INDEX as usize, "an index collided");
|
||||
}
|
||||
|
||||
/// cosmic-comp's `random_workspace_id` is `{:x}` of a number below 2<<24,
|
||||
/// so it is never more than seven hex digits. Ours are always eight, which
|
||||
/// is what makes a collision impossible rather than unlikely.
|
||||
#[test]
|
||||
fn ids_cannot_collide_with_a_compositor_generated_one() {
|
||||
for i in 1..=MAX_INDEX {
|
||||
let id = workspace_id(i);
|
||||
assert_eq!(id.len(), 8, "{id}");
|
||||
assert!(
|
||||
u32::from_str_radix(&id, 16).unwrap() >= 0x1000_0000,
|
||||
"{id} is inside the compositor's own range"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quote_in_a_name_cannot_break_out_of_the_ron() {
|
||||
let ron = render(&[ws(r#"1, name:say "hi""#)], false);
|
||||
assert!(ron.contains(r#"name: Some("say \"hi\"")"#), "{ron}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Adversarial checks on theme-archive extraction.
|
||||
//!
|
||||
//! These are deliberately independent of `assets.rs`'s own unit tests. Theme
|
||||
//! tarballs are downloaded from third-party repositories and extracted into the
|
||||
//! user's home directory, so "a test named `path_traversal_is_rejected` passes"
|
||||
//! is not sufficient evidence — these assert on the *filesystem* afterwards,
|
||||
//! proving nothing escaped rather than trusting a returned error.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cosmic_conf::assets::Installer;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Build a `.tar.gz` containing arbitrary entries, including hostile ones a
|
||||
/// well-behaved archiver would refuse to produce.
|
||||
fn hostile_tarball(path: &Path, entries: &[(&str, tar::EntryType, &[u8], Option<&str>)]) {
|
||||
let file = fs::File::create(path).unwrap();
|
||||
let mut builder = tar::Builder::new(GzEncoder::new(file, Compression::default()));
|
||||
|
||||
for (name, kind, data, link_target) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(*kind);
|
||||
header.set_mode(0o644);
|
||||
header.set_size(if link_target.is_some() {
|
||||
0
|
||||
} else {
|
||||
data.len() as u64
|
||||
});
|
||||
|
||||
// `append_data`/`set_path` reject `..` and absolute paths, so a hostile
|
||||
// archive cannot be produced through the safe API. Write the raw name
|
||||
// bytes into the GNU header directly — this is precisely what a
|
||||
// malicious archiver does, and the only way to test the guard honestly.
|
||||
write_raw_name(&mut header, name);
|
||||
if let Some(target) = link_target {
|
||||
write_raw_link(&mut header, target);
|
||||
}
|
||||
header.set_cksum();
|
||||
|
||||
builder.append(&header, *data).unwrap();
|
||||
}
|
||||
builder.into_inner().unwrap().finish().unwrap();
|
||||
}
|
||||
|
||||
/// Overwrite the GNU header's `name` field with arbitrary bytes, bypassing the
|
||||
/// validation `Header::set_path` performs.
|
||||
fn write_raw_name(header: &mut tar::Header, name: &str) {
|
||||
let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header");
|
||||
gnu.name = [0u8; 100];
|
||||
let bytes = name.as_bytes();
|
||||
assert!(bytes.len() < 100, "fixture name too long for a GNU header");
|
||||
gnu.name[..bytes.len()].copy_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// Same, for the `linkname` field.
|
||||
fn write_raw_link(header: &mut tar::Header, target: &str) {
|
||||
let gnu = header.as_gnu_mut().expect("new_gnu produces a GNU header");
|
||||
gnu.linkname = [0u8; 100];
|
||||
let bytes = target.as_bytes();
|
||||
assert!(bytes.len() < 100, "fixture link target too long");
|
||||
gnu.linkname[..bytes.len()].copy_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// A theme directory just complete enough for `plan` to consider the archive.
|
||||
fn theme_with_archive(
|
||||
entries: &[(&str, tar::EntryType, &[u8], Option<&str>)],
|
||||
) -> (TempDir, PathBuf, PathBuf) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let theme_dir = tmp.path().join("Configs/.config/hyde/themes/Evil");
|
||||
let source_dir = tmp.path().join("Source");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::create_dir_all(&source_dir).unwrap();
|
||||
fs::write(
|
||||
theme_dir.join("hypr.theme"),
|
||||
"general {\n gaps_in = 3\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
hostile_tarball(&source_dir.join("Gtk_Evil.tar.gz"), entries);
|
||||
(tmp, theme_dir, source_dir)
|
||||
}
|
||||
|
||||
/// Anything created outside the sandbox root is an escape.
|
||||
fn assert_nothing_outside(canary: &Path) {
|
||||
assert!(
|
||||
!canary.exists(),
|
||||
"archive extraction escaped its destination and wrote {}",
|
||||
canary.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_dir_traversal_never_writes_outside_destination() {
|
||||
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||
"../../../../../../tmp/cosmic_conf_escape_canary",
|
||||
tar::EntryType::Regular,
|
||||
b"pwned",
|
||||
None,
|
||||
)]);
|
||||
|
||||
let home = tmp.path().join("home");
|
||||
let data = home.join(".local/share");
|
||||
let installer = Installer::with_paths(&data, &home);
|
||||
|
||||
let result = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true);
|
||||
|
||||
// Whether it is rejected at plan time or apply time, the invariant is the
|
||||
// same: nothing lands outside the destination.
|
||||
if let Ok(plan) = result {
|
||||
let _ = installer.apply(&plan);
|
||||
}
|
||||
assert_nothing_outside(Path::new("/tmp/cosmic_conf_escape_canary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_entry_never_writes_outside_destination() {
|
||||
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||
"/tmp/cosmic_conf_abs_canary",
|
||||
tar::EntryType::Regular,
|
||||
b"pwned",
|
||||
None,
|
||||
)]);
|
||||
|
||||
let home = tmp.path().join("home");
|
||||
let data = home.join(".local/share");
|
||||
let installer = Installer::with_paths(&data, &home);
|
||||
|
||||
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true) {
|
||||
let _ = installer.apply(&plan);
|
||||
}
|
||||
assert_nothing_outside(Path::new("/tmp/cosmic_conf_abs_canary"));
|
||||
}
|
||||
|
||||
/// The subtle one: neither entry path contains `..`, so a naive check passes.
|
||||
/// The symlink redirects a later, innocent-looking write outside the tree.
|
||||
#[test]
|
||||
fn symlink_indirection_never_writes_outside_destination() {
|
||||
let (tmp, theme_dir, source_dir) = theme_with_archive(&[
|
||||
("escape", tar::EntryType::Symlink, b"", Some("/tmp")),
|
||||
(
|
||||
"escape/cosmic_conf_symlink_canary",
|
||||
tar::EntryType::Regular,
|
||||
b"pwned",
|
||||
None,
|
||||
),
|
||||
]);
|
||||
|
||||
let home = tmp.path().join("home");
|
||||
let data = home.join(".local/share");
|
||||
let installer = Installer::with_paths(&data, &home);
|
||||
|
||||
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true) {
|
||||
let _ = installer.apply(&plan);
|
||||
}
|
||||
assert_nothing_outside(Path::new("/tmp/cosmic_conf_symlink_canary"));
|
||||
}
|
||||
|
||||
/// A benign archive must still install, or the guard is uselessly strict.
|
||||
#[test]
|
||||
fn well_formed_archive_still_installs() {
|
||||
let (tmp, theme_dir, source_dir) = theme_with_archive(&[(
|
||||
"Evil-Theme/index.theme",
|
||||
tar::EntryType::Regular,
|
||||
b"[Desktop Entry]\n",
|
||||
None,
|
||||
)]);
|
||||
|
||||
let home = tmp.path().join("home");
|
||||
let data = home.join(".local/share");
|
||||
let installer = Installer::with_paths(&data, &home);
|
||||
|
||||
let plan = installer
|
||||
.plan(&theme_dir, Some(&source_dir), "Evil", None, true)
|
||||
.expect("a well-formed archive must plan cleanly");
|
||||
installer.apply(&plan).expect("and must apply");
|
||||
|
||||
assert!(
|
||||
home.join(".themes/Evil-Theme/index.theme").exists(),
|
||||
"benign archive did not install; guard is too strict"
|
||||
);
|
||||
}
|
||||
+1
-1
Submodule cosmic-edit updated: 4b75c32bff...4ac0da3af9
+1
-1
Submodule cosmic-files updated: cd43d722d6...24e34eaa0f
+1
-1
Submodule cosmic-greeter updated: 5527c9b866...d39915ae23
+1
-1
Submodule cosmic-icons updated: 52ad55cba5...b78b059636
+1
-1
Submodule cosmic-idle updated: 7899fccbbe...c95d066b5b
Submodule
+1
Submodule cosmic-initial-setup added at b5ac4182bb
+1
-1
Submodule cosmic-launcher updated: b617a9dc68...8799503120
Submodule
+1
Submodule cosmic-monitor added at 70e6cff168
+1
-1
Submodule cosmic-notifications updated: ea717d7938...7c723b7705
+1
-1
Submodule cosmic-osd updated: 5dd5f23148...20a2055dfc
+1
-1
Submodule cosmic-panel updated: dc429bc571...d6699ffc42
+1
-1
Submodule cosmic-player updated: 5796e09704...23d59445af
+1
-1
Submodule cosmic-randr updated: c247019230...6e8e795970
+1
-1
Submodule cosmic-screenshot updated: 10a564d9db...fc778df20f
+1
-1
Submodule cosmic-session updated: 6d16513d77...75267c5977
+1
-1
Submodule cosmic-settings updated: d853267ef1...7287257ec9
+1
-1
Submodule cosmic-settings-daemon updated: 58ff27f972...21a9692b53
Submodule
+1
Submodule cosmic-sound-theme added at 7aabe44909
+1
-1
Submodule cosmic-store updated: 28039ef6fc...f56cb48aa1
+1
-1
Submodule cosmic-term updated: b05076be65...543cbb0cb8
+1
-1
Submodule cosmic-wallpapers updated: cb8e6d653b...3c59953e7e
+1
-1
Submodule cosmic-workspaces-epoch updated: 7f877f72a3...8faab4c2a9
+2
-2
@@ -34,8 +34,8 @@ https://gitlab.freedesktop.org/wlroots/wlroots/-/wikis/DRM-Debugging documents h
|
||||
|
||||
## Frozen desktop
|
||||
|
||||
If the desktop is frozen and `ctrl+alt+f*` don't work to change to a TTY, [the magic SysRq key](https://www.kernel.org/doc/html/latest/admin-guide/sysrq.html) with `r` can be used to swich input in raw mode, so the kernel will handle the tty switch key binding. /etc/sysctl.conf` or `/etc/sysctl.d` may need to be edited first to set `kernel.sysrq=1` or another value that allows this command.
|
||||
If the desktop is frozen and `ctrl+alt+f*` don't work to change to a TTY, [the magic SysRq key](https://www.kernel.org/doc/html/latest/admin-guide/sysrq.html) with `r` can be used to switch input into raw mode, so the kernel will handle the tty switch key binding. `/etc/sysctl.conf` or `/etc/sysctl.d` may need to be edited first to set `kernel.sysrq=1` or another value that allows this command.
|
||||
|
||||
It is also possible to connect over `ssh` from another computer.
|
||||
|
||||
Then `pidof cosmic-comp` can be used to get the PID of the compositor. Then it is possible to investigate further with tools like `gdb`, using `sudo gdb` then `attach <pid>`.
|
||||
Then `pidof cosmic-comp` can be used to get the PID of the compositor. Then it is possible to investigate further with tools like `gdb`, using `sudo gdb` and then `attach <pid>`.
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# HyprCosmic — Design
|
||||
|
||||
**Date:** 2026-08-09
|
||||
**Status:** Approved for implementation
|
||||
|
||||
## Goal
|
||||
|
||||
Run a HyDE-style desktop on COSMIC's compositor: HyDE themes apply end to end — palette,
|
||||
wallpaper, gaps, rounding, bar, launcher, notifications — with all configuration driven from a
|
||||
single commented, version-controllable text file in Hyprland's idiom.
|
||||
|
||||
The user reviewed the cost of the full-rice target and chose it explicitly over the cheaper
|
||||
palette-only option.
|
||||
|
||||
### What this actually is
|
||||
|
||||
HyprCosmic is **HyDE with cosmic-comp as the compositor**, not COSMIC restyled to look like HyDE.
|
||||
COSMIC's own shell surface — cosmic-panel applets, the workspace overview, and cosmic-settings'
|
||||
appearance controls — is replaced, not themed. This framing is the honest description and should
|
||||
appear in the project README.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Bit-compatibility with Hyprland's config parser. Syntax is familiar; an existing
|
||||
`hyprland.conf` will not work, because COSMIC's key names and concepts differ throughout.
|
||||
- Preserving cosmic-settings as a working appearance editor. Configuration is one-way: the file
|
||||
wins, and GUI edits are overwritten on next apply.
|
||||
- Gradient window borders. COSMIC's `active_hint` is a solid hint with no gradient support, and
|
||||
adding one is out of scope.
|
||||
- Matching Hyprland's window-management semantics (dwindle/master layouts). cosmic-comp's BSP
|
||||
tiler stays.
|
||||
|
||||
## Verified findings
|
||||
|
||||
Everything below was read from the tree at `/home/dingo/cosmic-epoch`, not assumed.
|
||||
|
||||
| Finding | Evidence |
|
||||
|---|---|
|
||||
| cosmic-comp is GPL-3.0-only; forking is permitted | `cosmic-comp/src/lib.rs:7` |
|
||||
| `COSMIC_SESSION_SOCK` is optional — cosmic-comp runs standalone | `cosmic-comp/src/session.rs:76,90` |
|
||||
| wlr-layer-shell is implemented (foreign bars can render) | `cosmic-comp/src/wayland/handlers/layer_shell.rs` |
|
||||
| ext-session-lock is implemented | `cosmic-comp/src/wayland/handlers/session_lock.rs` |
|
||||
| ext-workspace-v1 is implemented, plus a cosmic v2 extension | `cosmic-comp/src/wayland/protocols/workspace/ext.rs` |
|
||||
| `zwlr_foreign_toplevel_management_v1` is **absent** — only ext-foreign-toplevel-list exists | grep across `cosmic-comp/src/`; `handlers/foreign_toplevel_list.rs` |
|
||||
| Gaps are real and theme-driven, `(u32, u32)` | `layout/tiling/mod.rs:4305`, `layout/floating/mod.rs:1689` |
|
||||
| Blur exists but is **client-requested** via `ext-background-effect`, not compositor rule | `handlers/background_effect.rs`, `backend/render/wayland/blur_effect.rs`, `shaders/blur_{downsample,upsample}.frag` |
|
||||
| Shadow and rounded-corner shaders exist | `backend/render/shaders/{shadow,rounded_rectangle,rounded_outline}.frag` |
|
||||
| Animation engine exists; durations hardcoded | `src/lib.rs:197,209`; `shell/workspace.rs:75` |
|
||||
| Window rules cover **tiling exceptions only** | `cosmic-settings-daemon/config/src/window_rules/mod.rs:41` |
|
||||
| Compositor config surface is ~20 flat fields | `cosmic-comp/cosmic-comp-config/src/lib.rs:71-105` |
|
||||
| cosmic-config is a filesystem KV store, one file per key, sparse (only changed keys materialise) | `~/.config/cosmic/`, 136 files across ~25 components |
|
||||
| cosmic-config live-reloads via inotify | `cosmic-comp/src/config/mod.rs:173,219,251` |
|
||||
| cosmic-panel has **zero** CSS/stylesheet support; renders via iced | grep across `cosmic-panel/` |
|
||||
| Upstream velocity: 46 commits in 30 days | `git log --since="30 days ago"` in cosmic-comp |
|
||||
|
||||
### What a HyDE theme actually contains
|
||||
|
||||
Measured from `HyDE-Project/hyde-themes`, branch `Catppuccin-Mocha` (25 files):
|
||||
|
||||
| File | Size | Contents |
|
||||
|---|---|---|
|
||||
| `hypr.theme` | 1,316 B | gaps 3/8, `rounding 10`, `border_size 2`, gradient borders, `blur {size 6, passes 3}`, GTK/icon theme names |
|
||||
| `waybar.theme` | 358 B | 7 `@define-color` lines — **not** a stylesheet |
|
||||
| `rofi.theme` | 320 B | ~7 colour variables |
|
||||
| `kitty.theme` | 1,536 B | palette |
|
||||
| GTK + icon tarballs | 4.6 MB | standard themes |
|
||||
| wallpapers | ~95 MB | the bulk |
|
||||
|
||||
The theme is ~3.5 KB of text. The bespoke widget styling belongs to HyDE itself, not to any
|
||||
individual theme — which is why running HyDE's own bar and launcher is the shortest path to
|
||||
fidelity.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three repositories. Upstream components not listed are consumed unmodified.
|
||||
|
||||
| Repo | Kind | Purpose |
|
||||
|---|---|---|
|
||||
| `hyprcosmic/cosmic-comp` | Fork (GPL-3.0) | Protocol patches, then blur/animation config |
|
||||
| `hyprcosmic/cosmic-conf` | New (GPL-3.0) | Config compiler + theme importer |
|
||||
| `hyprcosmic/hyprcosmic` | New meta | Submodule pins, session definition, docs |
|
||||
|
||||
Runtime composition:
|
||||
|
||||
| Layer | Component | Modified? |
|
||||
|---|---|---|
|
||||
| Compositor | `hyprcosmic/cosmic-comp` | Yes — patches A, B, then polish |
|
||||
| Bar | waybar (upstream, MIT) | No — consumes HyDE config + CSS directly |
|
||||
| Launcher | rofi (upstream) | No — HyDE `.rasi` works |
|
||||
| Notifications | swaync (upstream) | No |
|
||||
| Wallpaper | swww (upstream) | No — matches HyDE; `CosmicBackground` unused |
|
||||
| Session | forked cosmic-session | Yes — gate `start_component` calls |
|
||||
| Config | `cosmic-conf` | New |
|
||||
|
||||
## Phase 1 — `cosmic-conf`
|
||||
|
||||
A Rust binary that compiles one text file into the cosmic-config tree. It is a compiler, not a
|
||||
daemon owning state: COSMIC components keep reading cosmic-config and keep live-reloading through
|
||||
their existing `ConfigWatchSource`. Nothing in COSMIC learns about `cosmic.conf`.
|
||||
|
||||
### Units
|
||||
|
||||
| Unit | Responsibility | Depends on |
|
||||
|---|---|---|
|
||||
| `parser` | text → AST with byte spans. Sections, `$variables`, `source=`, `#` comments | — |
|
||||
| `schema` | Declarative registry: conf key → cosmic-config target + type + validator + doc | — |
|
||||
| `resolve` | AST + schema → typed values. Variable expansion, type/range checking, diagnostics | `parser`, `schema` |
|
||||
| `emit` | Typed values → cosmic-config writes | `resolve`, `cosmic-config` |
|
||||
| `watch` | inotify on the conf file and its includes → re-run pipeline | all |
|
||||
|
||||
`parser`, `schema` and `resolve` are pure and touch nothing COSMIC-specific, so the hard logic is
|
||||
unit-testable without a compositor running. Only `emit` binds to `cosmic-config`, and it is the
|
||||
only unit Phase 2 modifies when new keys land.
|
||||
|
||||
Entry points: `cosmic-conf apply` (one-shot, non-zero exit on error), `cosmic-conf watch`,
|
||||
`cosmic-conf apply --diff` (show what would be overwritten).
|
||||
|
||||
### File format
|
||||
|
||||
Hyprland-style syntax, hand-written recursive-descent parser (~500 lines). Chosen over KDL and
|
||||
TOML because the authoring experience is the product requirement; a better-engineered format that
|
||||
feels wrong fails the goal.
|
||||
|
||||
```
|
||||
$accent = rgb(6b9fed)
|
||||
$gap = 8
|
||||
|
||||
general {
|
||||
gaps_in = $gap
|
||||
gaps_out = $gap * 2
|
||||
autotile = true
|
||||
active_hint = true
|
||||
}
|
||||
|
||||
decoration {
|
||||
rounding = 10
|
||||
}
|
||||
|
||||
theme {
|
||||
mode = dark
|
||||
accent = $accent
|
||||
}
|
||||
|
||||
bind = SUPER, Return, spawn, kitty
|
||||
bind = SUPER, Q, close
|
||||
|
||||
source = ~/.config/hyprcosmic/monitors.conf
|
||||
```
|
||||
|
||||
### Schema registry
|
||||
|
||||
The mapping is not 1:1. Some conf keys own a whole cosmic-config value; others own one field
|
||||
inside a composite RON value (`decoration.rounding` targets one radius among six in
|
||||
`corner_radii`; `gaps_in`/`gaps_out` are two halves of one `(u32, u32)`).
|
||||
|
||||
```rust
|
||||
enum Target {
|
||||
Direct { component: &'static str, version: u8, key: &'static str },
|
||||
Projected { component: &'static str, version: u8, key: &'static str,
|
||||
path: &'static [&'static str] },
|
||||
}
|
||||
|
||||
Entry {
|
||||
conf: "general.gaps_in",
|
||||
// Fan-out: Dark and Light are separate cosmic-config components
|
||||
targets: &[
|
||||
Projected { component: "com.system76.CosmicTheme.Dark.Builder", version: 1,
|
||||
key: "gaps", path: &["1"] },
|
||||
Projected { component: "com.system76.CosmicTheme.Light.Builder", version: 1,
|
||||
key: "gaps", path: &["1"] },
|
||||
],
|
||||
ty: Ty::U32,
|
||||
validate: Some(range(0..=128)),
|
||||
doc: "Gap between adjacent tiled windows, in px",
|
||||
}
|
||||
```
|
||||
|
||||
**Spike-corrected facts** (verified in `vendor/libcosmic`):
|
||||
|
||||
- `gaps: (u32, u32)` lives on `ThemeBuilder` (`cosmic-theme/src/model/theme.rs:895`), **not** `CosmicTk`.
|
||||
Component IDs at `theme.rs:17-26`. Default `(0, 8)`.
|
||||
- Tuple order is **`(outer, inner)`** — so `gaps_out` is index `0` and `gaps_in` is index `1`.
|
||||
- Dark and Light Builders are **separate components**, so one conf key fans out to two targets.
|
||||
`Entry` therefore carries `targets: &[Target]`, not a single target.
|
||||
- `CosmicTk` (`libcosmic/src/config/mod.rs:14`, ID `com.system76.CosmicTk`) holds
|
||||
`icon_theme`, `interface_font`, `monospace_font`, `header_size`, `interface_density`,
|
||||
`show_minimize`, `show_maximize`, `apply_theme_global` — `icon_theme` is needed by the HyDE
|
||||
importer, which sets `$ICON_THEME`.
|
||||
|
||||
**`emit` writes through the typed `cosmic-config` API, not raw files.** `Config::watch`
|
||||
(`cosmic-config/src/lib.rs:377`) is a `notify` inotify watch on the config directory that derives
|
||||
changed keys from file paths, so raw writes would in fact be observed — but `Config::set` gives
|
||||
correct RON encoding per type, atomic writes via `atomicwrites::AtomicFile` (`lib.rs:513`), and
|
||||
matches the watcher's `.atomicwrite` temp-file filter (`lib.rs:408`). cosmic-conf therefore
|
||||
depends on `cosmic-theme`, `cosmic-comp-config` and `cosmic-settings-config` for the concrete
|
||||
types, which also buys compile-time type checking of the registry.
|
||||
|
||||
**Critical correctness property:** projected writes are read-modify-write, and multiple conf keys
|
||||
can share one target. `emit` MUST group by target key, fold all projections, then write once.
|
||||
Naïve per-key writes let `gaps_out` clobber `gaps_in`. This is directly unit-testable and is the
|
||||
highest-value test in the suite.
|
||||
|
||||
`doc` generates `cosmic.conf.default`, so the annotated reference file cannot drift from the
|
||||
schema.
|
||||
|
||||
### Phase 1 scope
|
||||
|
||||
| Section | Targets | Confidence |
|
||||
|---|---|---|
|
||||
| `general` | `CosmicComp`: autotile, active_hint, focus_follows_cursor(+delay), cursor_follows_focus, edge_snap_threshold, cursor_hide_timeout | Verified |
|
||||
| `workspace` | `CosmicComp/workspaces`: mode, layout, wraparound, action_on_typing | Verified |
|
||||
| `input` | `CosmicComp`: xkb_config, input_default, input_touchpad | Verified |
|
||||
| `bind` | `CosmicSettings.Shortcuts/custom`, incl. `Spawn(String)` | Verified |
|
||||
| `windowrule` | `WindowRules`: tiling exceptions only | Verified, deliberately thin |
|
||||
| `theme` | `CosmicTheme.Mode/is_dark`, `.Builder/{palette,corner_radii,spacing}` | **Unverified** |
|
||||
| `decoration` | `corner_radii`, `gaps` | **Unverified** |
|
||||
|
||||
### Spikes (must complete before schema work)
|
||||
|
||||
1. **Fetch libcosmic and enumerate `cosmic-theme` and `CosmicTk`.** `gaps` was inferred from its
|
||||
use site (`theme.cosmic().gaps`); the struct has not been read. If `gaps` is derived rather
|
||||
than stored, that row moves to Phase 2 and needs a compositor patch.
|
||||
2. **Determine whether direct RON file writes trigger `ConfigWatchSource`,** or whether `emit`
|
||||
must go through the typed `cosmic-config` API. Decides `emit`'s implementation.
|
||||
|
||||
### Error handling
|
||||
|
||||
The pipeline is transactional: `resolve` fully validates before `emit` writes anything. A
|
||||
malformed file leaves the desktop untouched rather than half-applied. Diagnostics report against
|
||||
source text with spans:
|
||||
|
||||
```
|
||||
error: unknown key `gaps_inn` in section `general`
|
||||
--> cosmic.conf:7:5
|
||||
|
|
||||
7 | gaps_inn = 8
|
||||
| ^^^^^^^^ did you mean `gaps_in`?
|
||||
```
|
||||
|
||||
## Phase 2 — cosmic-comp patches
|
||||
|
||||
Ordered by ascending risk. Each is independently shippable. Patches A and B are additive new
|
||||
files that never touch `shell/layout/tiling/mod.rs` — a 235 KB file that is the most painful
|
||||
thing in the tree to carry patches against.
|
||||
|
||||
### Patch A — `zwlr_foreign_toplevel_management_v1`
|
||||
|
||||
New protocol handler alongside `toplevel_info.rs` / `toplevel_management.rs`, which already hold
|
||||
the required state. Unlocks waybar's `wlr/taskbar`. Plausibly upstreamable. **Rebase risk: low.**
|
||||
|
||||
### Patch B — Hyprland-compatible IPC socket
|
||||
|
||||
Implement a subset of Hyprland's IPC at
|
||||
`$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock` (request/response) and `.socket2.sock`
|
||||
(event stream).
|
||||
|
||||
- Requests: `workspaces`, `activeworkspace`, `activewindow`, `clients`, `monitors`
|
||||
- Events: `workspace>>`, `activewindow>>`, `openwindow>>`, `closewindow>>`
|
||||
|
||||
HyDE's `hyprland/workspaces` and `hyprland/window` waybar modules then work unmodified, because
|
||||
waybar cannot tell the difference. Also delivers the `hyprctl`-style IPC from the original
|
||||
wishlist. New file, no entanglement with the layout engine. **Rebase risk: low.**
|
||||
|
||||
### Polish patches
|
||||
|
||||
| Patch | Where | Effort | Rebase risk |
|
||||
|---|---|---|---|
|
||||
| Animation curves + durations | `shell/`, config struct | Medium | Low — replaces consts with config lookups |
|
||||
| Opacity + shadow config | `backend/render/`, `shadow.frag` | Medium | Low — shader exists, needs uniforms |
|
||||
| Per-monitor/workspace gaps | both layout modules | Low | Low |
|
||||
| Compositor-driven blur rules | `backend/render/wayland/blur_effect.rs` | High | Medium — inverts client-request model |
|
||||
| Real window rules | `shell/layout/tiling/mod.rs` | High | **High** — do last |
|
||||
|
||||
## Phase 3 — Session and theme importer
|
||||
|
||||
### Session
|
||||
|
||||
Fork cosmic-session and gate the hardcoded `start_component` calls (cosmic-panel,
|
||||
cosmic-launcher, cosmic-app-library, cosmic-osd, cosmic-workspaces) behind config. Forking is
|
||||
preferred over skipping cosmic-session entirely, because cosmic-session also propagates the
|
||||
compositor environment to systemd/D-Bus and pulls up `graphical-session.target`; without it,
|
||||
portals and D-Bus-activated apps break.
|
||||
|
||||
cosmic-greeter is retained. Display-manager changes are the easiest way to lose access to a
|
||||
machine.
|
||||
|
||||
Ships a `hyprcosmic.desktop` session entry **alongside** the existing COSMIC session, so the
|
||||
working desktop remains selectable at login throughout development.
|
||||
|
||||
### Theme importer
|
||||
|
||||
`cosmic-conf import-theme <path-or-hyde-branch>` — one-way into `cosmic.conf`, not straight into
|
||||
cosmic-config, so the result is readable and editable.
|
||||
|
||||
1. Parse `hypr.theme` with the Phase 1 parser (same grammar — this is where the syntax choice pays off)
|
||||
2. Map recognised keys through a translation table into HyprCosmic conf keys
|
||||
3. Extract the palette; derive COSMIC's palette from border/accent colours via the Builder's
|
||||
tinting inputs (`neutral_tint`, `accent`, `bg_color`)
|
||||
4. Install GTK/icon tarballs, register wallpapers
|
||||
5. Copy `waybar.theme`, `rofi.theme`, `kitty.theme` to their upstream destinations unmodified
|
||||
6. **Emit an explicit unsupported-keys report** rather than silently dropping — e.g.
|
||||
`col.active_border: gradient not supported (COSMIC active_hint is solid)`
|
||||
|
||||
The report is the honesty mechanism that keeps partial import from feeling broken.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| **caffyne-shell as the shell** | Python/GTK3 (93% Python), **no license file** (all rights reserved), 10 weeks old at evaluation. Protocol prerequisites were verified present in cosmic-comp, so this remains technically viable if the license is resolved. |
|
||||
| **CSS theming inside cosmic-panel** | Requires building a CSS cascade for a retained-mode iced UI. Large new subsystem, and the result still would not consume HyDE's `style.css` verbatim. |
|
||||
| **Teach cosmic-comp to read `cosmic.conf` natively** | The config surface spans ~25 components; a file parsed inside the compositor could only configure the compositor. Also the largest fork and breaks cosmic-settings outright. |
|
||||
| **Bidirectional config sync** | Round-tripping a commented file through a KV store reliably is hard; failure mode is silently mangling the user's file. |
|
||||
| **Patch cosmic-comp before building the config layer** | Nothing usable until late, and the fork would be driven by 136 individual files in the meantime. |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Upstream velocity (46 commits/30 days) makes rebasing costly | Keep patches additive and in new files; defer window rules; upstream Patch A if accepted |
|
||||
| Theme/decoration schema rows are unverified | Spike 1 gates schema work; rows move to Phase 2 if `gaps` proves derived |
|
||||
| Losing COSMIC's shell removes the appearance GUI | Accepted and documented; `--diff` makes one-way overwrites visible |
|
||||
| Compositor work is not verifiable without a real session | Phase 1 is fully testable headless; Phases 2–3 need a nested or TTY session |
|
||||
| Naming leans on two projects' marks | Personal fork is fine; rename before any wide distribution |
|
||||
|
||||
## Success criteria
|
||||
|
||||
1. `cosmic-conf apply` compiles a `cosmic.conf` into cosmic-config and COSMIC live-reloads it.
|
||||
2. A malformed conf produces a spanned diagnostic and writes nothing.
|
||||
3. `gaps_in` and `gaps_out` both land — proving projection folding works.
|
||||
4. waybar runs on cosmic-comp showing workspaces and active window via Patch B, unmodified HyDE config.
|
||||
5. `import-theme Catppuccin-Mocha` yields a matching palette, wallpaper, rounding and gaps, plus
|
||||
an accurate unsupported-keys report.
|
||||
6. `hyprcosmic.desktop` is selectable at login and the stock COSMIC session still works.
|
||||
@@ -0,0 +1,88 @@
|
||||
# A session that came up with no input, once, on 2026-08-10
|
||||
|
||||
**Status: not root-caused. Not reproduced since. Closed deliberately, not fixed.**
|
||||
|
||||
This is written down because the next person to hit it -- probably us -- will
|
||||
otherwise start the same investigation from scratch, and because most of the
|
||||
value here is the list of things it is *not*.
|
||||
|
||||
## What happened
|
||||
|
||||
One HyprCosmic session came up with a working bar and a blank desktop below it,
|
||||
and no keyboard or pointer input reached the compositor at all. No binding
|
||||
fired. The session had to be left via a VT switch.
|
||||
|
||||
After a reboot, the same configuration and the same binaries came up fine.
|
||||
Super+Return, Super+A and a bare Super tap were all confirmed working by the
|
||||
user, with independent evidence from a watcher process:
|
||||
|
||||
```
|
||||
12:07:48 SPAWN pid=6264 exe=/usr/bin/rofi cmd=rofi -show drun
|
||||
12:07:50 SPAWN pid=6433 exe=/usr/bin/rofi cmd=rofi -show drun
|
||||
12:07:51 EVENT activewindow>>com.system76.CosmicTerm,dingo@fedora:~ - COSMIC Terminal
|
||||
```
|
||||
|
||||
Super+Return shows as a window event rather than a spawn because cosmic-term is
|
||||
single-instance: the binding fired, the existing process took the request.
|
||||
|
||||
## Ruled out, with the evidence
|
||||
|
||||
Each of these was a live hypothesis that turned out to be wrong. They are listed
|
||||
so nobody re-runs them.
|
||||
|
||||
- **The fork's own patches.** The blank screen was a separate bug entirely (a
|
||||
missing `awww img` call in autostart -- the daemon was running and drawing
|
||||
nothing, so nothing reported a wallpaper missing). Patch B's IPC answered
|
||||
queries correctly throughout. Neither patch touches input.
|
||||
|
||||
- **Events dropped by `seats.for_device()` returning `None`.** This does drop
|
||||
input silently (`src/input/mod.rs:208-215`), which made it an attractive
|
||||
theory. But input demonstrably works on the same build, so whatever the fault
|
||||
was, it was not a permanent property of this code.
|
||||
|
||||
- **The modifier-only Super binding swallowing Super+Return.** It cannot. The
|
||||
match loop at `src/input/mod.rs:1915-1955` sets `modifiers_shortcut_queue` on
|
||||
press and fires on release, and critically it *does not early-return*, so a
|
||||
modifier-only binding cannot consume a normal binding sharing its modifier.
|
||||
|
||||
- **A shortcuts-config race at login.** The config's mtime was 08:49; the
|
||||
session started at 09:59:46. Nothing was being written during startup.
|
||||
|
||||
- **`Error reading from session socket` and `Unable to become drm master`.**
|
||||
Both appear in the log. Both also appear in stock COSMIC logins on this
|
||||
machine, so neither is a fork symptom. (An earlier claim in this project that
|
||||
the DRM message was absent post-reboot was wrong; it is present twice, for
|
||||
PID 1609.)
|
||||
|
||||
## The one thing that is suspicious
|
||||
|
||||
The broken session was the **fourth** compositor start on that boot: 08:06 (from
|
||||
`target/debug`), 08:08, 08:19, and 09:59. Every other session on that boot, and
|
||||
every session since a reboot, has been fine.
|
||||
|
||||
That points at accumulated per-boot session/seat state -- a previous compositor
|
||||
not having fully released its seat, or logind still holding devices for a
|
||||
session that had gone away -- rather than at anything in the configuration or
|
||||
the code. It is a guess. It was not confirmed, and confirming it would mean
|
||||
deliberately cycling compositors on a live desktop.
|
||||
|
||||
## If it happens again
|
||||
|
||||
Collect *before* rebooting, because a reboot destroys the only evidence:
|
||||
|
||||
1. `loginctl list-sessions` and `loginctl session-status` for each -- look for
|
||||
more than one active session, or a session in state `closing`.
|
||||
2. `ls -l /dev/input/by-path/` and whether the compositor's PID holds any of
|
||||
them open (`ls -l /proc/<pid>/fd | grep event`).
|
||||
3. The session log with `RUST_LOG=cosmic_comp::input=trace`. The logger honours
|
||||
`RUST_LOG` via `EnvFilter::try_from_default_env()` before adding its own
|
||||
`cosmic_comp={warn|debug}` directives, and those are less specific, so the
|
||||
trace directive wins.
|
||||
4. Whether `libinput debug-events` (as root, on a VT) sees the devices at all.
|
||||
That splits the fault cleanly: if libinput sees nothing, it is below the
|
||||
compositor and nothing in this repo can be the cause.
|
||||
|
||||
Do **not** try to clean up by name-matching processes. `pkill cosmic-*` and
|
||||
friends have twice killed this user's live desktop. Kill by a PID captured at
|
||||
spawn, or a process group after `setsid`, and confirm with
|
||||
`readlink /proc/<pid>/exe` first.
|
||||
@@ -8,51 +8,85 @@ 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
|
||||
{{ make }} -C cosmic-osd all
|
||||
{{ just }} cosmic-osd/build-release
|
||||
{{ just }} cosmic-panel/build-release
|
||||
{{ just }} cosmic-player/build-release
|
||||
{{ just }} cosmic-randr/build-release
|
||||
{{ just }} cosmic-screenshot/build-release
|
||||
{{ just }} cosmic-settings/build-release
|
||||
{{ make }} -C cosmic-settings-daemon all
|
||||
{{ just }} cosmic-session/all
|
||||
{{ just }} cosmic-session/build-release
|
||||
{{ just }} cosmic-store/build-release
|
||||
{{ just }} cosmic-term/build-release
|
||||
{{ make }} -C cosmic-wallpapers all
|
||||
{{ make }} -C cosmic-workspaces-epoch all
|
||||
{{ make }} -C xdg-desktop-portal-cosmic 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
|
||||
|
||||
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
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-icons/install
|
||||
{{ 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
|
||||
{{ make }} -C cosmic-osd install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-osd/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-panel/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-player/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-randr/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-screenshot/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-settings/install
|
||||
{{ make }} -C cosmic-settings-daemon install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ just }} rootdir={{rootdir}} prefix={{rootdir + prefix}} cosmic-session/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-session/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-store/install
|
||||
{{ just }} rootdir={{rootdir}} prefix={{prefix}} cosmic-term/install
|
||||
{{ make }} -C cosmic-wallpapers install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ make }} -C cosmic-workspaces-epoch install DESTDIR={{rootdir}} prefix={{prefix}}
|
||||
{{ make }} -C xdg-desktop-portal-cosmic 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
|
||||
|
||||
_mkdir dir:
|
||||
mkdir -p dir
|
||||
@@ -74,11 +108,14 @@ clean:
|
||||
rm -rf cosmic-applibrary/target
|
||||
rm -rf cosmic-bg/target
|
||||
rm -rf cosmic-comp/target
|
||||
rm -rf cosmic-conf/target
|
||||
rm -rf cosmic-edit/target
|
||||
{{ just }} cosmic-files/clean
|
||||
rm -rf cosmic-greeter/target
|
||||
{{ just }} cosmic-idle/clean
|
||||
{{ just }} cosmic-initial-setup/clean
|
||||
rm -rf cosmic-launcher/target
|
||||
{{ just }} cosmic-monitor/clean
|
||||
rm -rf cosmic-panel/target
|
||||
rm -rf cosmic-player/target
|
||||
rm -rf cosmic-notifications/target
|
||||
@@ -92,4 +129,5 @@ clean:
|
||||
{{ just }} cosmic-term/clean
|
||||
{{ make }} -C cosmic-wallpapers clean
|
||||
rm -rf cosmic-workspaces-epoch/target
|
||||
{{ just }} pop-launcher/clean
|
||||
rm -rf xdg-desktop-portal-cosmic/target
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# HyprCosmic, as one Arch package.
|
||||
#
|
||||
# There is no build() here, and no source array. This PKGBUILD packages a tree
|
||||
# that `just install` has already staged, which tools/make-packages.sh points at
|
||||
# through $HYPRCOSMIC_STAGEDIR. The reasoning is in packaging/fedora/hyprcosmic.spec
|
||||
# and applies identically: building 27 Rust components a second time under
|
||||
# makepkg, to produce bytes that already exist, costs hours and creates a way
|
||||
# for the packaged desktop and the built one to drift apart.
|
||||
#
|
||||
# The consequence, same as there: this package is only valid on the Arch that
|
||||
# built it. make-packages.sh builds it inside archlinux:base-devel so that is a
|
||||
# current Arch rather than whatever the host is.
|
||||
|
||||
pkgname=hyprcosmic
|
||||
pkgver=${HYPRCOSMIC_VERSION:-0.1.0}
|
||||
pkgrel=1
|
||||
pkgdesc="COSMIC configured in Hyprland's idiom, with a HyDE shell"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/outbackdingo/hyprcosmic"
|
||||
license=('GPL-3.0-only')
|
||||
|
||||
# cosmic-session is COSMIC itself, which this runs on rather than replaces: one
|
||||
# entry pulls the whole desktop, and cosmic-settings, cosmic-osd, the portal and
|
||||
# the rest are taken from the repositories at the version they were tested at.
|
||||
#
|
||||
# The rest is the HyDE shell. Without those the session starts to a blank
|
||||
# screen: no bar, no launcher, no wallpaper.
|
||||
depends=('cosmic-session' 'waybar' 'rofi-wayland' 'wayland' 'libxkbcommon'
|
||||
'libinput' 'seatd' 'mesa' 'pixman' 'libdisplay-info' 'systemd-libs')
|
||||
|
||||
# awww is in the AUR rather than in the repositories, so it cannot be a hard
|
||||
# depends without making the package uninstallable for anyone who has not built
|
||||
# it. Without it there is no wallpaper, which is recoverable; an unsatisfiable
|
||||
# dependency is not.
|
||||
optdepends=('awww: wallpaper daemon, required for HyDE theme wallpapers'
|
||||
'ttf-nerd-fonts-symbols: glyphs the waybar config draws with'
|
||||
'qt5ct: Qt application theming to match the GTK theme')
|
||||
|
||||
# No conflicts and no provides, deliberately. This installs beside COSMIC: its
|
||||
# binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
|
||||
# writes no path that cosmic-comp or cosmic-session owns. An earlier revision
|
||||
# took the cosmic-* names and could not be installed over a COSMIC system
|
||||
# without erasing the desktop it forked.
|
||||
|
||||
options=('!strip' '!debug')
|
||||
|
||||
package() {
|
||||
# Points at a tree `just install` has already staged. Failing loudly here
|
||||
# beats producing an empty package, which is what a bare `cp -a "$unset/."`
|
||||
# would do. The workflow sets it; by hand it is
|
||||
# just install "$PWD/stage" /usr
|
||||
# HYPRCOSMIC_STAGEDIR="$PWD/stage" makepkg --nodeps
|
||||
if [ -z "$HYPRCOSMIC_STAGEDIR" ] || [ ! -d "$HYPRCOSMIC_STAGEDIR/usr" ]; then
|
||||
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see .github/workflows/packages.yml" >&2
|
||||
return 1
|
||||
fi
|
||||
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
|
||||
|
||||
# No desktop-file-validate on the session entry. It rejects DesktopNames,
|
||||
# the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
|
||||
# Desktop Entry Specification registers keys for application launchers and
|
||||
# this is a session file. The copy Fedora ships as cosmic-session-1.5.0-1.fc44
|
||||
# fails the identical check -- so this is the validator's gap, not something
|
||||
# the fork introduced. The workflow checks what actually matters instead --
|
||||
# that Exec names a file this package installs -- against the staged tree,
|
||||
# before makepkg sees it.
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
Package: hyprcosmic
|
||||
Version: @VERSION@
|
||||
Architecture: amd64
|
||||
Maintainer: dingo <[email protected]>
|
||||
Section: x11
|
||||
Priority: optional
|
||||
Homepage: https://github.com/outbackdingo/hyprcosmic
|
||||
Installed-Size: @INSTALLED_SIZE@
|
||||
Depends: @SHLIB_DEPENDS@
|
||||
Recommends: waybar, rofi
|
||||
Suggests: fonts-hack-ttf, qt5ct
|
||||
Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
|
||||
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
||||
Hyprland's idiom and wears a HyDE-style shell.
|
||||
.
|
||||
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
||||
lines and $variables -- is compiled into COSMIC's own configuration tree by
|
||||
hyprcosmic-conf. The file is the source of truth: keys it names are applied at
|
||||
every login over whatever the settings UI last stored, and keys it does not
|
||||
name are left alone.
|
||||
.
|
||||
The shell is waybar, rofi and awww in place of cosmic-panel, cosmic-launcher
|
||||
and cosmic-bg, and HyDE themes are imported directly. The compositor is
|
||||
COSMIC's, with a Hyprland-compatible IPC socket so that HyDE's scripts and
|
||||
waybar's hyprland modules work unmodified.
|
||||
.
|
||||
This package carries the whole desktop, which on Debian it has to: COSMIC is
|
||||
not packaged there, in any suite, so there is nothing to depend on and nothing
|
||||
to install beside. The Fedora and Arch packages ship only this fork's three
|
||||
binaries and take the rest from the distribution. Here the forked binaries are
|
||||
hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, the components they
|
||||
launch are included, and the session entry is the HyprCosmic one alone.
|
||||
@@ -0,0 +1,167 @@
|
||||
# HyprCosmic, as one RPM.
|
||||
#
|
||||
# WHY THIS REPACKS RATHER THAN REBUILDS
|
||||
# -------------------------------------
|
||||
# There is no %build here. The spec packages a tree that `just install` has
|
||||
# already produced, which .github/workflows/packages.yml stages and passes in as
|
||||
# --define "stagedir ...".
|
||||
#
|
||||
# The alternative -- a spec that runs `just build` itself under rpmbuild -- is
|
||||
# the more orthodox shape and is wrong for this project. `just build` compiles
|
||||
# 27 Rust components; doing it a second time inside rpmbuild to produce bytes
|
||||
# identical to the ones already sitting in the tree costs hours and buys
|
||||
# nothing. Worse, it would let the packaged desktop and the `just install`
|
||||
# desktop drift apart, and the whole point of shipping a package is that the
|
||||
# two are the same thing.
|
||||
#
|
||||
# The cost of this choice, stated plainly: the resulting RPM is only valid on
|
||||
# the distribution it was built on. Nothing here is statically linked, so an RPM
|
||||
# built on Fedora 44 assumes Fedora 44's glibc, wayland, libinput and mesa. Build
|
||||
# it on the release you intend to install it on. The workflow does that by
|
||||
# running the whole job inside a container of the target distribution.
|
||||
#
|
||||
# WHY IT IS ONE PACKAGE, AND WHY IT IS A SMALL ONE
|
||||
# ------------------------------------------------
|
||||
# Fedora splits COSMIC into a package per component, which is right for a
|
||||
# distribution tracking upstream. This fork changes three of them -- the
|
||||
# compositor, the session and the config compiler it adds -- and they are
|
||||
# versioned and tested together, so one package is an accurate description of
|
||||
# what is actually supported.
|
||||
#
|
||||
# It is not a package per component and it is not the whole desktop either. The
|
||||
# build tree produces all of COSMIC, because it is COSMIC's tree, but shipping
|
||||
# all of it would mean owning files that 25 distribution packages already own.
|
||||
# The workflow reduces the staged tree to what this fork actually produces
|
||||
# before any of the three packaging recipes see it.
|
||||
|
||||
%global _hardened_build 1
|
||||
|
||||
# Debuginfo extraction re-links every binary in the tree and would add an hour
|
||||
# to a package whose binaries were built elsewhere anyway. There is nothing to
|
||||
# strip usefully here.
|
||||
%global debug_package %{nil}
|
||||
|
||||
Name: hyprcosmic
|
||||
Version: %{?ver}%{!?ver:0.1.0}
|
||||
Release: %{?rel}%{!?rel:1}%{?dist}
|
||||
Summary: COSMIC configured in Hyprland's idiom, with a HyDE shell
|
||||
|
||||
License: GPL-3.0-only
|
||||
URL: https://github.com/outbackdingo/hyprcosmic
|
||||
BuildArch: x86_64
|
||||
|
||||
# COSMIC itself, which this runs on rather than replaces.
|
||||
#
|
||||
# One line pulls the whole desktop, because cosmic-session requires every
|
||||
# component. That is exactly what is wanted: HyprCosmic forks the compositor,
|
||||
# the session and adds the config compiler, and takes cosmic-settings,
|
||||
# cosmic-osd, the portal and the rest from the distribution at the version the
|
||||
# distribution tested them at.
|
||||
#
|
||||
# There is deliberately no Conflicts and no Provides here. An earlier revision
|
||||
# had both, on the reading that a fork of the desktop replaces the desktop, and
|
||||
# it could not be installed: this package's files collided with 25 others in
|
||||
# rpm's transaction check, and satisfying that by claiming all 25 with Conflicts
|
||||
# would have erased cosmic-greeter, which on a stock Fedora COSMIC is the
|
||||
# display manager. Installing beside COSMIC costs three renamed binaries and
|
||||
# leaves the stock session on the greeter's menu to fall back to.
|
||||
Requires: cosmic-session >= 1.5.0
|
||||
|
||||
# The HyDE shell. These are separate programs this fork drives rather than
|
||||
# builds, and without them the session starts to a blank screen with no bar and
|
||||
# no launcher.
|
||||
Requires: waybar
|
||||
Requires: rofi-wayland
|
||||
|
||||
# The wallpaper daemon. Recommends rather than Requires because it lives in the
|
||||
# alebastr/sway-extras COPR rather than in Fedora proper, and a hard dependency
|
||||
# that cannot resolve would make this package uninstallable on a machine that
|
||||
# has not enabled that repository. Without it you get no wallpaper; with it and
|
||||
# no theme imported, you also get no wallpaper. Both are recoverable; an
|
||||
# unsatisfiable dependency is not.
|
||||
Recommends: awww
|
||||
|
||||
# Something for the wallpaper daemon to draw before a theme has been imported.
|
||||
# hyprcosmic-wallpaper looks here first when ~/.local/share/wallpapers has no
|
||||
# `current` link yet, which on a machine that has just installed this package is
|
||||
# always. Nothing in the dependency chain pulls it in otherwise -- neither
|
||||
# cosmic-session nor cosmic-comp requires it -- and without it a first login is
|
||||
# a bar on a black screen.
|
||||
Recommends: cosmic-wallpapers
|
||||
|
||||
# Nerd Font glyphs are most of what the bar and the launcher draw, and Fedora
|
||||
# has nothing that provides them. This line used to say `nerd-fonts`, which is
|
||||
# not a package in any Fedora repository -- so it could never be satisfied, and
|
||||
# the fonts never arrived, silently, because that is what Recommends does when
|
||||
# it cannot resolve. The two patched fonts Fedora does ship are no help either:
|
||||
# texlive-inconsolata-nerd-font lands under texmf-dist and kitty's
|
||||
# SymbolsNerdFont under /usr/lib64/kitty, and fontconfig scans neither path.
|
||||
#
|
||||
# So recommend the fallback the configuration actually names. rules.rasi asks
|
||||
# for "JetBrainsMono Nerd Font, Noto Sans Mono": this is the second of the two.
|
||||
# It cannot draw a single glyph, but it decides what the text is set in instead
|
||||
# of leaving that to whatever fontconfig happens to pick. A virtual provide
|
||||
# rather than a name, because two packages carry it and either will do.
|
||||
#
|
||||
# The glyphs themselves have to be installed by hand. See README.md.
|
||||
Recommends: font(notosansmono)
|
||||
|
||||
%description
|
||||
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
||||
Hyprland's idiom and wears a HyDE-style shell.
|
||||
|
||||
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
||||
lines and $variables -- is compiled into COSMIC's own configuration tree by
|
||||
hyprcosmic-conf. The file is the source of truth: keys it names are applied at every
|
||||
login over whatever the settings UI last stored, and keys it does not name are
|
||||
left alone.
|
||||
|
||||
The shell is waybar, rofi and awww in place of cosmic-panel, cosmic-launcher and
|
||||
cosmic-bg, and HyDE themes are imported directly. The compositor is COSMIC's,
|
||||
with a Hyprland-compatible IPC socket so that HyDE's scripts and waybar's
|
||||
hyprland modules work unmodified.
|
||||
|
||||
This package installs beside the distribution's COSMIC rather than over it. Its
|
||||
binaries are hyprcosmic-comp, hyprcosmic-session and hyprcosmic-conf, and it
|
||||
adds one session entry; the stock COSMIC entry stays on the greeter's menu,
|
||||
served by the distribution's own binaries, so a session that will not start is
|
||||
one logout away from a desktop that will.
|
||||
|
||||
%prep
|
||||
# Nothing to unpack. See the note at the top of this file.
|
||||
|
||||
%install
|
||||
test -n "%{stagedir}" || { echo "define stagedir: see .github/workflows/packages.yml" >&2; exit 1; }
|
||||
test -d "%{stagedir}/usr" || { echo "%{stagedir}/usr missing; run just install first" >&2; exit 1; }
|
||||
cp -a "%{stagedir}/." "%{buildroot}/"
|
||||
|
||||
# The session entry is checked by the workflow against the staged tree, not
|
||||
# with desktop-file-validate here. desktop-file-validate rejects DesktopNames,
|
||||
# the key a display manager reads to set XDG_CURRENT_DESKTOP, because the
|
||||
# Desktop Entry Specification registers keys for application launchers and this
|
||||
# is a session file. The copy Fedora already ships as cosmic-session-1.5.0-1.fc44
|
||||
# fails the identical check -- so this is the validator's gap, not something the
|
||||
# fork introduced. Dropping the key would satisfy the validator and break the
|
||||
# session.
|
||||
# See "Check the session entries" in .github/workflows/packages.yml, which
|
||||
# tests what actually matters: that Exec names a file this package installs.
|
||||
|
||||
# Generated by the workflow from the staged tree rather than written out here.
|
||||
# A hand-maintained list across 27 components would be wrong within a week, and
|
||||
# wrong in the direction that ships a package missing files nobody notices until
|
||||
# a login fails.
|
||||
%files -f %{filelist}
|
||||
|
||||
%changelog
|
||||
* Wed Aug 12 2026 dingo <[email protected]> - 0.1.1-1
|
||||
- Seed ~/.config from /usr/share/hyprcosmic/skel at every session start, so a
|
||||
machine that has never seen HyprCosmic logs into a configured desktop rather
|
||||
than a bare compositor. Existing files are never overwritten.
|
||||
- Recommend font(notosansmono) in place of nerd-fonts, which is not a package
|
||||
in any Fedora repository and so could never have been installed.
|
||||
- Recommend cosmic-wallpapers, and add hyprcosmic-wallpaper, which falls back
|
||||
to it when no theme has been imported yet.
|
||||
|
||||
* Mon Aug 10 2026 dingo <[email protected]> - 0.1.0-1
|
||||
- First package of the fork: hyprcosmic-comp, hyprcosmic-session and
|
||||
hyprcosmic-conf installed beside the distribution's COSMIC, with a HyDE shell.
|
||||
Submodule
+1
Submodule pop-launcher added at a332a3a733
+7
-6
@@ -2,14 +2,15 @@
|
||||
|
||||
set -e
|
||||
|
||||
TAG="$1"
|
||||
|
||||
if [ -z "$TAG" ]
|
||||
if [ -z "$1" ]
|
||||
then
|
||||
echo "$0 [tag]" >&2
|
||||
echo "$0 [epoch version]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
TAG="epoch-$1"
|
||||
|
||||
echo "Do you want to tag the current state of cosmic-epoch with the tag $TAG? (y/N)"
|
||||
read answer
|
||||
if [ "$answer" != "y" ]
|
||||
@@ -22,8 +23,8 @@ set -x
|
||||
|
||||
git fetch --recurse-submodules
|
||||
|
||||
git tag --force "$TAG"
|
||||
git submodule foreach git tag --force "$TAG"
|
||||
git tag --force --annotate "$TAG" --message "Epoch ${VERSION}"
|
||||
git submodule foreach git tag --force --annotate "$TAG" --message "Epoch ${VERSION}"
|
||||
|
||||
git push --force origin tag "$TAG"
|
||||
git submodule foreach git push --force origin tag "$TAG"
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# This should be the _next_ epoch version
|
||||
version=1.5.0
|
||||
subject="Epoch ${version} version update"
|
||||
description="Generated by cosmic-epoch scripts/version-update.sh"
|
||||
|
||||
repos=(
|
||||
cosmic-edit
|
||||
cosmic-files
|
||||
cosmic-monitor
|
||||
cosmic-player
|
||||
cosmic-store
|
||||
cosmic-term
|
||||
)
|
||||
|
||||
for repo in "${repos[@]}"
|
||||
do
|
||||
git submodule update --recursive "${repo}"
|
||||
pushd "${repo}"
|
||||
git fetch origin
|
||||
git checkout -B epoch-update origin/master
|
||||
git reset --hard
|
||||
if [ "$(dpkg-parsechangelog --show-field Version)" != "${version}" ]
|
||||
then
|
||||
dch --newversion "${version}" --distribution noble "${subject}"
|
||||
fi
|
||||
cargo set-version "${version}"
|
||||
cargo update
|
||||
git commit -a -m "${subject}" -m "${description}"
|
||||
git push -f -u origin epoch-update
|
||||
pr_url="$(gh pr list --head epoch-update --state open --json url --jq '.[].url')"
|
||||
if [ -n "${pr_url}" ]
|
||||
then
|
||||
echo "PR already open: ${pr_url}"
|
||||
else
|
||||
gh pr create \
|
||||
--title "${subject}" \
|
||||
--body "${description}" \
|
||||
--assignee "@me" \
|
||||
--reviewer "pop-os/engineering" \
|
||||
--reviewer "pop-os/quality-assurance"
|
||||
fi
|
||||
popd
|
||||
done
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# Install the parts of HyprCosmic that live outside a user's home directory.
|
||||
#
|
||||
# WHY THIS SCRIPT EXISTS
|
||||
# ----------------------
|
||||
# Everything here was placed by hand with `sudo install` while the desktop was
|
||||
# being built, which left two problems that this script exists to close:
|
||||
#
|
||||
# 1. A fresh machine has none of it. rofi's config.rasi @imports
|
||||
# /usr/share/hyprcosmic/rofi/{palette,rules}.rasi by absolute path, and a
|
||||
# missing @import is an error rofi renders *in place of the launcher*
|
||||
# rather than a warning it skips. Miss those two files and Super+A shows a
|
||||
# parse error instead of a menu.
|
||||
#
|
||||
# 2. Hand-installed files drift. /usr/bin/start-hyprcosmic silently gained a
|
||||
# session-logging block during debugging that never made it back to the
|
||||
# copy under version control; nothing noticed until the two were diffed on
|
||||
# a hunch. `--check` is the cure: it compares every managed file against
|
||||
# its source and exits non-zero on any difference.
|
||||
#
|
||||
# WHAT IT DOES NOT DO
|
||||
# -------------------
|
||||
# Per-user files. `cosmic-conf import-theme --assets` writes those, because they
|
||||
# depend on the installed theme and on $HOME; see PER_USER below for the list
|
||||
# and its owner. The cosmic-conf, cosmic-session and cosmic-comp binaries are
|
||||
# also out of scope. The top-level justfile installs them -- cosmic-conf from
|
||||
# its own `install` line, the other two from their component's recipe -- and
|
||||
# they are build outputs, so comparing them byte-for-byte here would only ever
|
||||
# report a rebuild.
|
||||
#
|
||||
# Usage:
|
||||
# tools/install-assets.sh install (needs write access to PREFIX)
|
||||
# tools/install-assets.sh --check compare only; exit 1 on any drift
|
||||
# tools/install-assets.sh --dry-run print what install would do
|
||||
# tools/install-assets.sh --no-session skip start-hyprcosmic and the .desktop
|
||||
#
|
||||
# Environment:
|
||||
# PREFIX install prefix, default /usr (see the warning below)
|
||||
# DESTDIR staging root for packaging, prepended to every path
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PREFIX="${PREFIX:-/usr}"
|
||||
DESTDIR="${DESTDIR:-}"
|
||||
|
||||
MODE=install
|
||||
WITH_SESSION=1
|
||||
|
||||
# Kept for the "re-run as sudo" hint, which has to quote the invocation the user
|
||||
# actually made; $@ is long gone by the time a write fails.
|
||||
ARGV=("$@")
|
||||
|
||||
die() { printf 'install-assets: %s\n' "$*" >&2; exit 1; }
|
||||
warn() { printf 'install-assets: %s\n' "$*" >&2; }
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--check) MODE=check ;;
|
||||
--dry-run) MODE=dry-run ;;
|
||||
--no-session) WITH_SESSION=0 ;;
|
||||
-h|--help) sed -n '2,/^set -uo/p' "${BASH_SOURCE[0]}" | sed 's/^# \?//;$d'; exit 0 ;;
|
||||
*) die "unknown argument: $1 (try --help)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Files under config/ that belong to the system, as "source:destination:mode".
|
||||
# Destinations are relative to $PREFIX.
|
||||
SHARED=(
|
||||
"config/waybar/bridge-hyde.css:share/hyprcosmic/waybar/bridge-hyde.css:644"
|
||||
"config/waybar/config.jsonc:share/hyprcosmic/waybar/config.jsonc:644"
|
||||
"config/waybar/palette.css:share/hyprcosmic/waybar/palette.css:644"
|
||||
"config/waybar/rules.css:share/hyprcosmic/waybar/rules.css:644"
|
||||
"config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644"
|
||||
"config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644"
|
||||
"config/bin/hyprcosmic-powermenu:bin/hyprcosmic-powermenu:755"
|
||||
"config/bin/hyprcosmic-fan:bin/hyprcosmic-fan:755"
|
||||
"config/bin/hyprcosmic-keybinds:bin/hyprcosmic-keybinds:755"
|
||||
"config/bin/hyprcosmic-wallpaper:bin/hyprcosmic-wallpaper:755"
|
||||
)
|
||||
|
||||
# The session entry point. Kept apart from SHARED because it is versioned in the
|
||||
# cosmic-session fork rather than in this repository -- that fork is a separate
|
||||
# checkout and may simply be absent, in which case these are skipped.
|
||||
SESSION=(
|
||||
"cosmic-session/data/start-hyprcosmic:bin/start-hyprcosmic:755"
|
||||
"cosmic-session/data/hyprcosmic.desktop:share/wayland-sessions/hyprcosmic.desktop:644"
|
||||
)
|
||||
|
||||
# Files under config/ that produce a SHARED file rather than being one. They are
|
||||
# inputs to a generator and have no place on the system: installing the template
|
||||
# would put a file full of @@TOKEN@@ placeholders next to the real config, and
|
||||
# whichever one a future reader opened first would be a coin toss.
|
||||
SOURCES=(
|
||||
"config/waybar/config.jsonc.in" # -> config/waybar/config.jsonc
|
||||
"config/waybar/generate-config.py" # the generator, and the icon table
|
||||
)
|
||||
|
||||
# Per-user files. These cannot live in share/hyprcosmic with the rest: style.css
|
||||
# and config.rasi reach their theme through relative @imports, which resolve
|
||||
# against the importing file, so the importer has to sit in the same per-user
|
||||
# directory as the theme it picks up.
|
||||
#
|
||||
# They are still installed, as a skeleton. start-hyprcosmic copies anything
|
||||
# missing out of share/hyprcosmic/skel into $XDG_CONFIG_HOME when a session
|
||||
# starts, and never overwrites. Destinations below therefore mirror the layout
|
||||
# under ~/.config exactly -- skel/hyprcosmic/autostart becomes
|
||||
# ~/.config/hyprcosmic/autostart -- because the seeding is a plain copy that
|
||||
# reads the layout off this tree rather than a list it keeps in step by hand.
|
||||
#
|
||||
# Before this existed the answer was "by hand", which meant a machine that had
|
||||
# never seen HyprCosmic logged into a bare compositor: no autostart, so no
|
||||
# waybar and no wallpaper, and no keybindings, on a screen with nothing drawn on
|
||||
# it. Nothing reported an error, because from the session's point of view
|
||||
# nothing had gone wrong.
|
||||
SKEL=(
|
||||
"config/autostart:share/hyprcosmic/skel/hyprcosmic/autostart:644"
|
||||
"config/cosmic.conf:share/hyprcosmic/skel/hyprcosmic/cosmic.conf:644"
|
||||
"config/waybar/style.css:share/hyprcosmic/skel/hyprcosmic/waybar/style.css:644"
|
||||
"config/waybar/theme.css:share/hyprcosmic/skel/hyprcosmic/waybar/theme.css:644"
|
||||
"config/rofi/config.rasi:share/hyprcosmic/skel/rofi/config.rasi:644"
|
||||
"config/rofi/theme.rasi:share/hyprcosmic/skel/rofi/theme.rasi:644"
|
||||
"config/rofi/local.rasi:share/hyprcosmic/skel/rofi/local.rasi:644"
|
||||
)
|
||||
|
||||
# Refuses to run unless every file under config/ appears in exactly one of
|
||||
# SHARED, SKEL or SOURCES. This is not decoration: adding a file forces a
|
||||
# decision about where it belongs instead of letting it be quietly left out of
|
||||
# all three and never installed.
|
||||
audit_config_tree() {
|
||||
local f rel known=" ${SOURCES[*]} " unclassified=()
|
||||
# Built with a loop, not `${SHARED[*]%%:*}`: that form strips the suffix
|
||||
# from the first element only and silently keeps the rest whole.
|
||||
for f in "${SHARED[@]}"; do known+="${f%%:*} "; done
|
||||
for f in "${SKEL[@]}"; do known+="${f%%:*} "; done
|
||||
|
||||
while IFS= read -r -d '' f; do
|
||||
rel="${f#"$REPO"/}"
|
||||
[[ "$known" == *" $rel "* ]] || unclassified+=("$rel")
|
||||
# Dot-directories are pruned. Nothing shipped lives in one, and tooling
|
||||
# drops state inside the tree without asking -- .omc/ appeared under
|
||||
# config/waybar/ and failed this audit with six files that are gitignored
|
||||
# and are not assets. Failing on those trains you to ignore the one message
|
||||
# that catches a genuinely unclassified file. Dot *files* are still walked;
|
||||
# only directories are pruned.
|
||||
done < <(find "$REPO/config" -name '.?*' -type d -prune -o -type f -print0 | sort -z)
|
||||
|
||||
((${#unclassified[@]} == 0)) || die "not listed as shared, skeleton or generator input: ${unclassified[*]}
|
||||
Add each to SHARED, SKEL or SOURCES in $(basename "${BASH_SOURCE[0]}") and say which."
|
||||
}
|
||||
|
||||
# The prefix is only half honoured, and pretending otherwise would be worse than
|
||||
# saying so. Some consumers name /usr/share/hyprcosmic as a literal because they
|
||||
# have no way to interpolate one: rofi's .rasi has no variables, and the
|
||||
# autostart file is explicitly not a shell. Report them by grepping rather than
|
||||
# from a hardcoded list, so this warning cannot go stale.
|
||||
check_prefix_assumptions() {
|
||||
[[ "$PREFIX" == /usr ]] && return 0
|
||||
local hits
|
||||
hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ cosmic-session/data/ 2>/dev/null | sort | tr '\n' ' ')"
|
||||
[[ -z "$hits" ]] && return 0
|
||||
warn "PREFIX=$PREFIX, but these name /usr/share/hyprcosmic literally and cannot interpolate it:"
|
||||
warn " $hits"
|
||||
warn "they will keep reading /usr/share unless you edit them; rofi will show a parse error if it is empty"
|
||||
warn "start-hyprcosmic is the exception: set HYPRCOSMIC_SKEL=$PREFIX/share/hyprcosmic/skel to point the seeding at this prefix"
|
||||
}
|
||||
|
||||
# Fail before touching anything rather than half way through. The nearest
|
||||
# existing ancestor is what matters: install(1) creates the leaf directories, so
|
||||
# an absent share/hyprcosmic/rofi is fine as long as share/ can be written.
|
||||
assert_writable() {
|
||||
local dest="$1" dir
|
||||
dir="$(dirname "$dest")"
|
||||
while [[ ! -e "$dir" && "$dir" != / ]]; do dir="$(dirname "$dir")"; done
|
||||
[[ -w "$dir" ]] && return 0
|
||||
if ((EUID != 0)); then
|
||||
die "$dir is not writable. Re-run as: sudo ${BASH_SOURCE[0]}${ARGV[*]:+ ${ARGV[*]}}"
|
||||
fi
|
||||
die "$dir is not writable, even as root"
|
||||
}
|
||||
|
||||
status=0
|
||||
installed=0 skipped=0 differs=0
|
||||
|
||||
handle() {
|
||||
local src="$REPO/$1" dest="$DESTDIR$PREFIX/$2" mode="$3"
|
||||
|
||||
if [[ ! -f "$src" ]]; then
|
||||
warn "missing source, skipped: $1"
|
||||
skipped=$((skipped + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
check)
|
||||
if [[ ! -e "$dest" ]]; then
|
||||
printf ' MISSING %s\n' "$dest"
|
||||
differs=$((differs + 1))
|
||||
elif cmp -s "$src" "$dest"; then
|
||||
printf ' ok %s\n' "$dest"
|
||||
else
|
||||
printf ' DIFFERS %s\n' "$dest"
|
||||
differs=$((differs + 1))
|
||||
fi
|
||||
;;
|
||||
dry-run)
|
||||
printf ' would install -m %s %s -> %s\n' "$mode" "$1" "$dest"
|
||||
;;
|
||||
install)
|
||||
assert_writable "$dest"
|
||||
install -D -m "$mode" "$src" "$dest" || die "failed to install $dest"
|
||||
printf ' %s\n' "$dest"
|
||||
installed=$((installed + 1))
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
audit_config_tree
|
||||
[[ "$MODE" == install ]] && check_prefix_assumptions
|
||||
|
||||
targets=("${SHARED[@]}" "${SKEL[@]}")
|
||||
if ((WITH_SESSION)); then
|
||||
if [[ -d "$REPO/cosmic-session/data" ]]; then
|
||||
targets+=("${SESSION[@]}")
|
||||
else
|
||||
warn "cosmic-session/data is absent; skipping the session entry point"
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
check) echo "Checking against $DESTDIR$PREFIX:" ;;
|
||||
dry-run) echo "Dry run against $DESTDIR$PREFIX:" ;;
|
||||
install) echo "Installing to $DESTDIR$PREFIX:" ;;
|
||||
esac
|
||||
|
||||
for spec in "${targets[@]}"; do
|
||||
IFS=: read -r src dest mode <<<"$spec"
|
||||
handle "$src" "$dest" "$mode"
|
||||
done
|
||||
|
||||
case "$MODE" in
|
||||
check)
|
||||
if ((differs)); then
|
||||
echo "$differs file(s) missing or out of date; re-run without --check to fix" >&2
|
||||
status=1
|
||||
else
|
||||
echo "All ${#targets[@]} file(s) match."
|
||||
fi
|
||||
;;
|
||||
install)
|
||||
echo "Installed $installed file(s)."
|
||||
((skipped)) && echo "Skipped $skipped missing source(s)." >&2
|
||||
;;
|
||||
esac
|
||||
|
||||
exit $status
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/bash
|
||||
#
|
||||
# Run the forked cosmic-session nested inside the live desktop, safely.
|
||||
#
|
||||
# WHY THIS SCRIPT EXISTS
|
||||
# ----------------------
|
||||
# On 2026-08-10 an ad-hoc version of this test logged the developer out of their
|
||||
# own desktop. Two independent mistakes did it, and both are easy to repeat by
|
||||
# hand, so the test lives in a script instead:
|
||||
#
|
||||
# 1. `pkill -x cosmic-session` matched the *real* session leader. The fork and
|
||||
# the system COSMIC ship binaries with the same name, so no name-based
|
||||
# match can distinguish them. This script therefore never uses pkill or
|
||||
# pgrep; it kills the process group it created, by ID.
|
||||
#
|
||||
# 2. A nested cosmic-session on the shared session bus takes the well-known
|
||||
# D-Bus name `com.system76.CosmicSession` away from the running session
|
||||
# (journal: "Connection `:1.3` lost name `com.system76.CosmicSession`").
|
||||
# That destabilises the outer desktop before anything is even killed. This
|
||||
# script always runs under `dbus-run-session`, so the nested session gets a
|
||||
# private bus and cannot touch the real one's names.
|
||||
#
|
||||
# Nesting cosmic-comp alone is safe and does not need any of this; the hazard is
|
||||
# specific to running a second cosmic-session.
|
||||
#
|
||||
# Usage: tools/nested-session.sh [seconds] [-- extra env assignments]
|
||||
# e.g. tools/nested-session.sh 12 -- HYPRCOSMIC_PROFILE=hyprcosmic
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SESSION_BIN="$REPO/cosmic-session/target/debug/cosmic-session"
|
||||
COMP_BIN="$REPO/cosmic-comp/target/debug/cosmic-comp"
|
||||
|
||||
DURATION="${1:-12}"
|
||||
shift || true
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
|
||||
die() { printf 'nested-session: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Refuse to run outside a Wayland session. Without a host compositor the winit
|
||||
# backend would fall back to DRM and try to take over the real display.
|
||||
[[ -n "${WAYLAND_DISPLAY:-}" ]] || die "no WAYLAND_DISPLAY; refusing to run (would grab the DRM device)"
|
||||
[[ -x "$SESSION_BIN" ]] || die "not built: $SESSION_BIN"
|
||||
[[ -x "$COMP_BIN" ]] || die "not built: $COMP_BIN"
|
||||
command -v dbus-run-session >/dev/null || die "dbus-run-session is required for bus isolation"
|
||||
|
||||
# Record the live session's leader purely so the exit check can prove we did not
|
||||
# disturb it. Asked of logind rather than matched by process name: a name-based
|
||||
# lookup is what destroyed the developer's session twice, once in the very test
|
||||
# written to prove name matching was unsafe. There is no `ps -C cosmic-...`
|
||||
# anywhere in this file, deliberately.
|
||||
OUTER_LEADER="$(loginctl show-session "${XDG_SESSION_ID:-}" -p Leader --value 2>/dev/null)"
|
||||
|
||||
LOG="$(mktemp -t nested-session.XXXXXX.log)"
|
||||
echo "nested-session: logging to $LOG"
|
||||
echo "nested-session: live session leader=$OUTER_LEADER (must survive)"
|
||||
|
||||
# setsid puts the whole tree in a fresh process group whose ID equals the child
|
||||
# PID, so one negative kill reaps the session, the compositor and every
|
||||
# component it spawned -- with no pattern matching anywhere.
|
||||
setsid env \
|
||||
COSMIC_BACKEND=winit \
|
||||
RUST_LOG="${RUST_LOG:-info}" \
|
||||
"$@" \
|
||||
dbus-run-session -- "$SESSION_BIN" "$COMP_BIN" >"$LOG" 2>&1 &
|
||||
PGID=$!
|
||||
|
||||
cleanup() {
|
||||
# Negative PID = process group. Never a name.
|
||||
kill -TERM -"$PGID" 2>/dev/null
|
||||
for _ in $(seq 20); do
|
||||
kill -0 -"$PGID" 2>/dev/null || break
|
||||
sleep 0.25
|
||||
done
|
||||
kill -KILL -"$PGID" 2>/dev/null
|
||||
|
||||
# An abruptly-killed compositor leaves its IPC directory behind, so drop any
|
||||
# whose owning PID is gone. Matching is on the PID embedded in the name.
|
||||
for dir in "${XDG_RUNTIME_DIR:?}"/hypr/cosmic_*; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
pid="${dir##*/cosmic_}"; pid="${pid%%_*}"
|
||||
kill -0 "$pid" 2>/dev/null || rm -rf "$dir"
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
sleep "$DURATION"
|
||||
cleanup
|
||||
trap - EXIT INT TERM
|
||||
|
||||
# The whole point: confirm the developer still has a desktop.
|
||||
status=0
|
||||
if [[ -n "$OUTER_LEADER" ]] && ! kill -0 "$OUTER_LEADER" 2>/dev/null; then
|
||||
echo "nested-session: FAIL - live session leader $OUTER_LEADER died during the test" >&2
|
||||
status=1
|
||||
else
|
||||
echo "nested-session: live session survived"
|
||||
fi
|
||||
|
||||
echo "--- log: $LOG ---"
|
||||
exit $status
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that the fork's Hyprland IPC is reachable the way a real client reaches it.
|
||||
|
||||
Run this inside a HyprCosmic session after installing a new cosmic-comp. It
|
||||
exercises the two things that are easy to get wrong and impossible to see from
|
||||
the compositor's own log:
|
||||
|
||||
1. The socket *names*. Every Hyprland client -- waybar's hyprland/* modules,
|
||||
hyprctl, eww, ags -- opens `$XDG_RUNTIME_DIR/hypr/$HIS/.socket.sock` and
|
||||
gives up if it is absent. waybar reports it once, at startup, as
|
||||
"Couldn't connect to ... (3)" and then disables the module, so a bar with
|
||||
a silently missing workspace widget is the only symptom you get.
|
||||
|
||||
2. The dispatch (write) endpoint, which is what makes clicking a workspace on
|
||||
the bar actually switch to it rather than just look clickable.
|
||||
|
||||
Nothing here changes configuration. `dispatch workspace` moves the focused
|
||||
workspace, which is runtime state, so this script does disturb what you are
|
||||
looking at: it returns to the workspace you started on when it finishes.
|
||||
|
||||
Exit status is 0 only if every check passed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
RESET, RED, GREEN, DIM = "\033[0m", "\033[31m", "\033[32m", "\033[2m"
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def result(ok: bool, label: str, detail: str = "") -> bool:
|
||||
mark = f"{GREEN}ok{RESET}" if ok else f"{RED}FAIL{RESET}"
|
||||
print(f" [{mark}] {label}")
|
||||
if detail:
|
||||
for line in str(detail).splitlines():
|
||||
print(f" {DIM}{line}{RESET}")
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
return ok
|
||||
|
||||
|
||||
def socket_dir() -> str:
|
||||
runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
||||
base = os.path.join(runtime, "hypr")
|
||||
if not os.path.isdir(base):
|
||||
print(f"{RED}No {base}. Is this a HyprCosmic session?{RESET}")
|
||||
sys.exit(2)
|
||||
sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
|
||||
if not sig:
|
||||
# Newest instance, so this still works from a terminal that predates it.
|
||||
entries = sorted(
|
||||
(e for e in os.listdir(base) if os.path.isdir(os.path.join(base, e))),
|
||||
key=lambda e: os.stat(os.path.join(base, e)).st_mtime,
|
||||
)
|
||||
if not entries:
|
||||
print(f"{RED}No instance directory under {base}.{RESET}")
|
||||
sys.exit(2)
|
||||
sig = entries[-1]
|
||||
print(f"{DIM}HYPRLAND_INSTANCE_SIGNATURE unset; using newest: {sig}{RESET}")
|
||||
return os.path.join(base, sig)
|
||||
|
||||
|
||||
def request(path: str, payload: str, timeout: float = 2.0) -> str:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(timeout)
|
||||
s.connect(path)
|
||||
s.sendall(payload.encode())
|
||||
chunks = []
|
||||
while True:
|
||||
data = s.recv(8192)
|
||||
if not data:
|
||||
break
|
||||
chunks.append(data)
|
||||
return b"".join(chunks).decode(errors="replace")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
d = socket_dir()
|
||||
req_path = os.path.join(d, ".socket.sock")
|
||||
evt_path = os.path.join(d, ".socket2.sock")
|
||||
|
||||
print(f"\ninstance dir: {d}\n")
|
||||
|
||||
print("socket names (the names clients actually open)")
|
||||
have_req = result(os.path.exists(req_path), ".socket.sock exists")
|
||||
result(os.path.exists(evt_path), ".socket2.sock exists")
|
||||
stale = [n for n in (".socket", ".socket2") if os.path.exists(os.path.join(d, n))]
|
||||
result(not stale, "no unsuffixed leftovers", ", ".join(stale) if stale else "")
|
||||
|
||||
if not have_req:
|
||||
print(f"\n{RED}Request socket missing; cannot go further.{RESET}")
|
||||
print("An old cosmic-comp is probably still running -- the rename only")
|
||||
print("takes effect for a session started after installing the binary.")
|
||||
return 1
|
||||
|
||||
print("\nread endpoints")
|
||||
active = None
|
||||
for cmd in ("workspaces", "activeworkspace", "activewindow", "clients", "monitors"):
|
||||
try:
|
||||
reply = request(req_path, f"j/{cmd}")
|
||||
ok = reply.strip().startswith(("{", "["))
|
||||
result(ok, f"j/{cmd}", "" if ok else f"unexpected reply: {reply[:200]}")
|
||||
if cmd == "activeworkspace" and ok:
|
||||
import json
|
||||
|
||||
active = json.loads(reply).get("id")
|
||||
except OSError as e:
|
||||
result(False, f"j/{cmd}", e)
|
||||
|
||||
print("\nunknown commands are refused, not guessed at")
|
||||
for cmd in ("bogus", "dispatch exec rofi", "dispatch killactive",
|
||||
"dispatch workspace +1", "dispatch workspace 0"):
|
||||
try:
|
||||
reply = request(req_path, cmd).strip()
|
||||
# An unparsed request gets no useful answer; what matters is that it
|
||||
# is not silently treated as something else.
|
||||
result(not reply.startswith("ok"), f"refuses {cmd!r}", f"reply: {reply[:120]}")
|
||||
except OSError as e:
|
||||
result(False, f"refuses {cmd!r}", e)
|
||||
|
||||
print("\nwrite endpoint")
|
||||
if active is None:
|
||||
result(False, "know the current workspace to return to")
|
||||
else:
|
||||
target = 2 if active != 2 else 1
|
||||
try:
|
||||
reply = request(req_path, f"dispatch workspace {target}").strip()
|
||||
result(reply == "ok", f"dispatch workspace {target}", f"reply: {reply[:120]}")
|
||||
back = request(req_path, f"dispatch workspace {active}").strip()
|
||||
result(back == "ok", f"back to workspace {active}", f"reply: {back[:120]}")
|
||||
except OSError as e:
|
||||
result(False, "dispatch workspace", e)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{RED}{len(failures)} check(s) failed:{RESET}")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
return 1
|
||||
print(f"{GREEN}All checks passed.{RESET}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Submodule xdg-desktop-portal-cosmic updated: a821b5a778...f211aa37d4
Reference in New Issue
Block a user