mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 07:10:09 +00:00
Compare commits
11
Commits
7df465ef26
...
c80566768c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80566768c | ||
|
|
06f974f09f | ||
|
|
e006e95e55 | ||
|
|
111f3ade7b | ||
|
|
0e6dcdc606 | ||
|
|
28042b9879 | ||
|
|
5bb057cb71 | ||
|
|
43507504f4 | ||
|
|
49d5f8b20e | ||
|
|
fc8c2a727d | ||
|
|
5f521529a8 |
@@ -171,13 +171,19 @@ jobs:
|
||||
|
||||
# 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
|
||||
|
||||
# Both scripts are shellcheck-clean today, so this starts as a ratchet
|
||||
# Every script is shellcheck-clean today, so this starts as a ratchet
|
||||
# rather than a backlog. The runner image ships shellcheck; the install is
|
||||
# there so that stopping to be true is a slow step and not a broken job.
|
||||
- name: Shellcheck
|
||||
@@ -187,7 +193,10 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck
|
||||
}
|
||||
shellcheck config/bin/hyprcosmic-powermenu tools/install-assets.sh
|
||||
shellcheck config/bin/hyprcosmic-fan \
|
||||
config/bin/hyprcosmic-keybinds \
|
||||
config/bin/hyprcosmic-powermenu \
|
||||
tools/install-assets.sh
|
||||
|
||||
# A round trip. Installing into a staging root and then asking --check to
|
||||
# confirm it exercises both halves of the script against each other, and
|
||||
|
||||
+173
-21
@@ -229,28 +229,169 @@ jobs:
|
||||
- name: Stage the install
|
||||
run: just install "$PWD/stage" /usr
|
||||
|
||||
# The staged tree is what all three packages wrap, so it is worth failing
|
||||
# here rather than shipping a package that is missing the compositor. The
|
||||
# negative assertion is the one that would rot quietly: nothing may return
|
||||
# to the private libexec layout this fork used to install into, because a
|
||||
# copy there is a second compositor that nothing runs and no uninstall
|
||||
# removes.
|
||||
- name: Assert the staged tree is a complete desktop
|
||||
# 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
|
||||
test -x stage/usr/bin/cosmic-comp
|
||||
test -x stage/usr/bin/cosmic-session
|
||||
test -x stage/usr/bin/cosmic-conf
|
||||
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 -x stage/usr/bin/start-cosmic
|
||||
test -f stage/usr/share/wayland-sessions/hyprcosmic.desktop
|
||||
test -f stage/usr/share/wayland-sessions/cosmic.desktop
|
||||
test -f stage/usr/lib/systemd/user/cosmic-session.target
|
||||
test -f stage/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1/defaults
|
||||
test -d stage/usr/share/hyprcosmic
|
||||
test ! -e stage/usr/libexec/hyprcosmic
|
||||
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
|
||||
@@ -325,7 +466,11 @@ jobs:
|
||||
# dependencies without touching the filesystem, and this image is not
|
||||
# a desktop, so a real install would fail on runtime libraries that
|
||||
# say nothing about whether the package is well formed.
|
||||
pacman -Qlp dist/*.pkg.tar.zst | head -20
|
||||
# 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 -------------------------------------------------------
|
||||
#
|
||||
@@ -364,7 +509,13 @@ jobs:
|
||||
run: |
|
||||
set -eux
|
||||
dpkg-deb --info dist/*.deb
|
||||
dpkg-deb --contents dist/*.deb | head -20
|
||||
# To a file and then head, not `| head`. Actions runs these steps with
|
||||
# pipefail, and head closing the pipe after 20 lines kills dpkg-deb
|
||||
# with SIGPIPE, which pipefail reports as a failed step -- a green
|
||||
# 217 MB package failed here on nothing but that.
|
||||
dpkg-deb --contents dist/*.deb > contents.txt
|
||||
echo "entries: $(wc -l < contents.txt)"
|
||||
head -20 contents.txt
|
||||
# lintian is not installed and would fail this package on a dozen
|
||||
# policy points that do not apply to a desktop fork shipped outside
|
||||
# the archive. What matters here is that dpkg can read it back.
|
||||
@@ -395,10 +546,11 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
# Draft, deliberately. These packages conflict with the
|
||||
# distribution's cosmic-comp and cosmic-session, so installing one
|
||||
# replaces the machine's desktop. That is worth a human reading the
|
||||
# notes before it is published rather than a tag push making it
|
||||
# available.
|
||||
# Draft, 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
|
||||
|
||||
@@ -18,12 +18,13 @@ Three things distinguish a HyprCosmic session from a COSMIC one:
|
||||
- **HyDE's shell.** waybar instead of cosmic-panel, rofi instead of
|
||||
cosmic-launcher, `awww` instead of cosmic-bg. HyDE themes are imported
|
||||
directly, palette and wallpapers and all.
|
||||
- **It replaces COSMIC rather than sitting next to it.** The binaries install as
|
||||
`/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, the paths a cosmic-comp
|
||||
and a cosmic-session go to, and the packages conflict with the distribution's
|
||||
accordingly. Both session entries are installed, so the greeter still offers a
|
||||
stock COSMIC shell for the day the HyDE one does not start — now served by
|
||||
these binaries rather than by a second copy on disk.
|
||||
- **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).)
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -54,8 +55,9 @@ current.
|
||||
deliberately small: `dispatch exec` and `dispatch killactive` are rejected,
|
||||
because this is the surface any process that can open the socket gets.
|
||||
- New windows open *beside* the focused window rather than inside it.
|
||||
- The install goes to `/usr/bin/cosmic-comp`, at upstream's paths and alongside
|
||||
upstream's two `.ron` defaults files, which are carried unmodified.
|
||||
- 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.
|
||||
|
||||
**cosmic-session** — profiles. `HYPRCOSMIC_PROFILE=hyprcosmic` (set by
|
||||
`hyprcosmic.desktop`) skips cosmic-panel, cosmic-launcher, cosmic-app-library,
|
||||
@@ -93,14 +95,24 @@ sudo pacman -U ./hyprcosmic-*.pkg.tar.zst # Arch
|
||||
sudo dpkg -i ./hyprcosmic_*_amd64.deb # Debian
|
||||
```
|
||||
|
||||
Expect this to fail the first time, and read what it says when it does. These
|
||||
packages provide `/usr/bin/cosmic-comp` and `/usr/bin/cosmic-session`, so they
|
||||
**conflict with the distribution's `cosmic-comp` and `cosmic-session`** and your
|
||||
package manager will refuse until those are removed. That refusal is the design:
|
||||
installing HyprCosmic replaces the machine's desktop, and it should take a
|
||||
deliberate `dnf remove cosmic-comp cosmic-session` to say so rather than a
|
||||
resolver deciding on your behalf. Both session entries survive the swap, so the
|
||||
greeter still offers a stock COSMIC shell afterwards.
|
||||
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.
|
||||
|
||||
An earlier revision did take the `cosmic-*` names, and it could not be
|
||||
installed. Its files collided with 25 distribution packages, and the only way to
|
||||
satisfy that was to erase them — including cosmic-greeter, which on a Fedora
|
||||
COSMIC install *is* the display manager. A desktop you can only try by removing
|
||||
the desktop you would fall back to is not one worth shipping.
|
||||
|
||||
**Debian is the exception**, because COSMIC is not packaged there — no
|
||||
`cosmic-session`, no `cosmic-comp`, in any suite. There is nothing to depend on
|
||||
and nothing to install beside, so the `.deb` carries the whole desktop it
|
||||
compiled and stands alone, and the greeter offers **HyprCosmic** only. The
|
||||
Fedora and Arch packages ship this fork's three binaries and nothing else.
|
||||
|
||||
Building it yourself instead:
|
||||
|
||||
@@ -125,6 +137,13 @@ This installs all of COSMIC — the 27 unmodified components as well — plus
|
||||
`cosmic-conf` at `$prefix/bin/cosmic-conf`, the shared waybar and rofi assets
|
||||
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.
|
||||
@@ -134,26 +153,85 @@ Then log out. `HyprCosmic` appears on the greeter's session menu next to
|
||||
|
||||
### Per-user setup
|
||||
|
||||
`just install` places nothing in a home directory — under `sudo` the only home
|
||||
directory it could see is root's. Four files are yours to place:
|
||||
Nothing to do. The session seeds `~/.config` from `/usr/share/hyprcosmic/skel`
|
||||
at login, copying only what is missing:
|
||||
|
||||
```shell
|
||||
mkdir -p ~/.config/hyprcosmic/waybar
|
||||
cp config/cosmic.conf config/autostart ~/.config/hyprcosmic/
|
||||
cp config/waybar/style.css ~/.config/hyprcosmic/waybar/
|
||||
```
|
||||
~/.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
|
||||
```
|
||||
|
||||
`style.css` is per-user rather than shared for one reason: it `@import`s a
|
||||
sibling `theme.css` holding the installed HyDE theme's palette, and a relative
|
||||
`@import` resolves against the importing file. That sibling is written by
|
||||
`import-theme --assets`, so the bar is unstyled until you have imported a theme.
|
||||
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.
|
||||
|
||||
The fourth file, `~/.config/rofi/config.rasi`, is written by `import-theme
|
||||
--assets` too, because it names per-machine paths.
|
||||
The cost of that is real and worth stating: an upgrade that improves a shipped
|
||||
default will not reach a file you already have. If a release changes something
|
||||
you want — the `autostart` line that sets the wallpaper did change once — take
|
||||
it deliberately, after reading what you would lose:
|
||||
|
||||
```shell
|
||||
diff -u ~/.config/hyprcosmic/autostart /usr/share/hyprcosmic/skel/hyprcosmic/autostart
|
||||
cp /usr/share/hyprcosmic/skel/hyprcosmic/autostart ~/.config/hyprcosmic/
|
||||
```
|
||||
|
||||
`autostart` is the one that matters most, because it is what starts waybar, the
|
||||
wallpaper daemon and `hyprcosmic-conf watch`. Until the session seeded it, a
|
||||
machine that had never run HyprCosmic logged in to a bare compositor: running,
|
||||
holding the display and accepting input, with nothing drawn on the screen and no
|
||||
binding that opened anything.
|
||||
|
||||
`style.css` and `config.rasi` are per-user rather than shared for one reason:
|
||||
each `@import`s a sibling holding the installed HyDE theme's palette, and a
|
||||
relative `@import` resolves against the importing file. Those siblings —
|
||||
`theme.css`, `theme.rasi` and `local.rasi` — arrive empty and are written by
|
||||
`import-theme --assets`. They ship empty rather than not at all because a
|
||||
missing `@import` is fatal to both consumers rather than a warning they skip:
|
||||
GTK fails the entire stylesheet, and rofi reports the error in place of the
|
||||
launcher.
|
||||
|
||||
Working from a git checkout, `just install` still places nothing in a home
|
||||
directory — under `sudo` the only home directory it could see is root's — but it
|
||||
does install the skeleton, so logging in seeds the same seven files.
|
||||
|
||||
Runtime dependencies of the shell itself are not COSMIC's and are not built
|
||||
here: `waybar`, `rofi` (wayland build), `awww` (formerly `swww`), and a Nerd
|
||||
Font for the bar's glyphs.
|
||||
Font for the glyphs the bar and the launcher draw with.
|
||||
|
||||
The wallpaper is `awww`'s job, and the image it draws is
|
||||
`~/.local/share/wallpapers/hyprcosmic/current` — a symlink `import-theme
|
||||
--assets` maintains. Before you have imported a theme there is no such link, so
|
||||
`hyprcosmic-wallpaper` falls back to whatever the distribution ships,
|
||||
`/usr/share/backgrounds/cosmic` first; the Fedora package recommends
|
||||
`cosmic-wallpapers` so there is something there. With no `awww` installed it
|
||||
says so on the session log and leaves the background alone, rather than waiting
|
||||
for a daemon that is never coming.
|
||||
|
||||
The font is the one thing the packaging cannot do for you on Fedora, which has
|
||||
no package that provides a Nerd Font at all: `texlive-inconsolata-nerd-font`
|
||||
installs under `texmf-dist` and kitty's `SymbolsNerdFont` under
|
||||
`/usr/lib64/kitty`, and fontconfig scans neither. Arch has
|
||||
`ttf-nerd-fonts-symbols`. Otherwise, install one into your own font directory:
|
||||
|
||||
```shell
|
||||
mkdir -p ~/.local/share/fonts/JetBrainsMonoNerdFont
|
||||
# unpack JetBrainsMono.zip from github.com/ryanoasis/nerd-fonts/releases there
|
||||
fc-cache -f
|
||||
fc-list ":charset=e0b0" family | grep -i nerd # non-empty: the glyphs resolve
|
||||
```
|
||||
|
||||
Query the charset on its own, as above. Adding a family filter —
|
||||
`":charset=e0b0:family=JetBrainsMono Nerd Font"` — reports nothing even when the
|
||||
font does cover the codepoint, because the family string is a comma-separated
|
||||
alias list (`JetBrainsMono Nerd Font,JetBrainsMono NF`). Without the font the
|
||||
bar still works; every icon is a tofu box.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -161,9 +239,9 @@ Font for the bar's glyphs.
|
||||
`cosmic-config` by:
|
||||
|
||||
```shell
|
||||
cosmic-conf apply # once
|
||||
cosmic-conf apply --diff # show what would change, write nothing
|
||||
cosmic-conf watch # recompile on every edit, for the whole session
|
||||
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
|
||||
@@ -208,7 +286,7 @@ the reference for what is supported.
|
||||
## Theming
|
||||
|
||||
```shell
|
||||
cosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \
|
||||
hyprcosmic-conf import-theme ~/.config/hyde/themes/'Tokyo Night'/hypr.theme \
|
||||
--out ~/.config/hyprcosmic/theme.conf --report --assets
|
||||
```
|
||||
|
||||
|
||||
+15
-17
@@ -23,7 +23,7 @@
|
||||
# A malformed edit is not fatal. It is reported to the session log and the last
|
||||
# good configuration stays in place, so a typo cannot leave you at a broken
|
||||
# desktop -- fix the file and the next save applies.
|
||||
cosmic-conf watch
|
||||
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
|
||||
@@ -60,28 +60,26 @@ awww-daemon
|
||||
# get a blank screen below the bar with no error anywhere: the failure is that
|
||||
# nobody asked for a wallpaper, so nothing reports one missing.
|
||||
#
|
||||
# `sh -c` rather than a bare `awww img`, for one reason: awww-daemon above has
|
||||
# only just been forked and is not listening yet, so an immediate `awww img`
|
||||
# loses a race and fails silently. The loop waits for the daemon to answer
|
||||
# before setting the image.
|
||||
# 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.
|
||||
#
|
||||
# This does not weaken the no-shell rule in the header. That rule exists so a
|
||||
# file naming programs cannot be escalated into arbitrary execution; naming
|
||||
# `sh` explicitly is just naming a program, and anyone able to write this file
|
||||
# could already name any binary on the system.
|
||||
#
|
||||
# `current` is a symlink `cosmic-conf import-theme --assets` maintains beside
|
||||
# the wallpapers it copies, pointing at one of them. It is named here rather
|
||||
# than a real file so that this line and rofi's local.rasi -- which shows the
|
||||
# same image in the launcher's sidebar -- cannot drift apart, and so that
|
||||
# importing a different theme does not leave this pointing at a path that no
|
||||
# longer exists.
|
||||
# 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
|
||||
sh -c 'until awww query >/dev/null 2>&1; do sleep 0.2; done; exec awww img "$HOME/.local/share/wallpapers/hyprcosmic/current"'
|
||||
#
|
||||
# A program rather than the `sh -c` this used to be, because waiting for the
|
||||
# daemon needs a bound: awww is a Recommends in a COPR, and the old unbounded
|
||||
# `until awww query` loop spun for the whole session on a machine without it.
|
||||
hyprcosmic-wallpaper
|
||||
|
||||
# A terminal, unconditionally, as the way back in.
|
||||
#
|
||||
|
||||
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
+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"
|
||||
@@ -50,6 +50,80 @@ general {
|
||||
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
|
||||
@@ -111,3 +185,22 @@ bind = $mainMod, Return, exec, cosmic-term
|
||||
# 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,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,16 @@
|
||||
/* Step 2 of the import chain in config.rasi: the installed HyDE theme's rofi
|
||||
* colours.
|
||||
*
|
||||
* Empty on purpose, for the same reason as waybar's theme.css: a missing
|
||||
* @import is an error rofi reports in place of the launcher rather than a
|
||||
* warning it skips, so config.rasi can only import a file that is guaranteed
|
||||
* to exist. palette.rasi already defines every name rules.rasi reads, so
|
||||
* defining nothing here leaves a complete theme rather than an unresolved
|
||||
* reference.
|
||||
*
|
||||
* `hyprcosmic-conf import-theme` overwrites this with the imported theme's
|
||||
* rofi colours. Hand-written overrides belong in local.rasi, which is imported
|
||||
* after the rules and is left alone unless --overwrite is passed.
|
||||
*/
|
||||
|
||||
* {}
|
||||
+139
-1
@@ -50,15 +50,19 @@
|
||||
"height": 34,
|
||||
"spacing": 4,
|
||||
|
||||
"modules-left": ["ext/workspaces", "wlr/taskbar"],
|
||||
"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",
|
||||
@@ -67,6 +71,23 @@
|
||||
"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"
|
||||
@@ -80,6 +101,56 @@
|
||||
"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.
|
||||
@@ -120,6 +191,23 @@
|
||||
]
|
||||
},
|
||||
|
||||
// First on the right, and silent unless something is wrong. A unit that
|
||||
// failed at boot otherwise stays failed until the day you happen to run
|
||||
// `systemctl --failed` -- there is no other surface for it in a COSMIC
|
||||
// session, which has no equivalent of GNOME's abrt notification.
|
||||
//
|
||||
// Both scopes are watched. The user scope is where a session's own units
|
||||
// fail, which is the half that matters here and the half a system-only
|
||||
// check misses.
|
||||
"systemd-failed-units": {
|
||||
"format": "\udb80\udc26 {nr_failed}",
|
||||
"format-ok": "",
|
||||
"system": true,
|
||||
"user": true,
|
||||
"hide-on-ok": true,
|
||||
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
|
||||
},
|
||||
|
||||
"idle_inhibitor": {
|
||||
"format": "{icon}",
|
||||
"format-icons": {
|
||||
@@ -205,6 +293,19 @@
|
||||
"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.
|
||||
//
|
||||
@@ -220,6 +321,43 @@
|
||||
"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
|
||||
|
||||
@@ -50,15 +50,19 @@
|
||||
"height": 34,
|
||||
"spacing": 4,
|
||||
|
||||
"modules-left": ["ext/workspaces", "wlr/taskbar"],
|
||||
"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",
|
||||
@@ -67,6 +71,23 @@
|
||||
"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"
|
||||
@@ -80,6 +101,56 @@
|
||||
"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.
|
||||
@@ -120,6 +191,23 @@
|
||||
]
|
||||
},
|
||||
|
||||
// First on the right, and silent unless something is wrong. A unit that
|
||||
// failed at boot otherwise stays failed until the day you happen to run
|
||||
// `systemctl --failed` -- there is no other surface for it in a COSMIC
|
||||
// session, which has no equivalent of GNOME's abrt notification.
|
||||
//
|
||||
// Both scopes are watched. The user scope is where a session's own units
|
||||
// fail, which is the half that matters here and the half a system-only
|
||||
// check misses.
|
||||
"systemd-failed-units": {
|
||||
"format": "@@ALERT@@ {nr_failed}",
|
||||
"format-ok": "",
|
||||
"system": true,
|
||||
"user": true,
|
||||
"hide-on-ok": true,
|
||||
"on-click": "cosmic-term -e sh -c 'systemctl --failed; systemctl --user --failed; exec $SHELL'"
|
||||
},
|
||||
|
||||
"idle_inhibitor": {
|
||||
"format": "{icon}",
|
||||
"format-icons": {
|
||||
@@ -205,6 +293,19 @@
|
||||
"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.
|
||||
//
|
||||
@@ -220,6 +321,43 @@
|
||||
"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
|
||||
|
||||
@@ -55,6 +55,12 @@ ICONS = {
|
||||
"BAT_80": 0xF0081,
|
||||
"BAT_90": 0xF0082,
|
||||
"BAT_100": 0xF0079,
|
||||
"WINDOW": 0xF2D0,
|
||||
"ALERT": 0xF0026,
|
||||
"DISK": 0xF02CA,
|
||||
"GPU": 0xF08AE,
|
||||
"FAN": 0xF0210,
|
||||
"KEYBOARD": 0xF030C,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -77,15 +77,20 @@ window#waybar {
|
||||
/* Every status module gets the same pill. Listed one per line because waybar
|
||||
* takes the CSS id from the module name with `/` turned into `-`, so these are
|
||||
* 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,
|
||||
@@ -106,6 +111,12 @@ window#waybar {
|
||||
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;
|
||||
}
|
||||
@@ -137,12 +148,35 @@ window#waybar {
|
||||
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; }
|
||||
|
||||
@@ -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-comp updated: 5f176995dc...a73734edb0
Generated
+45
@@ -8,6 +8,15 @@ 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"
|
||||
@@ -29,6 +38,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"notify",
|
||||
"regex",
|
||||
"ron",
|
||||
"serde",
|
||||
"tar",
|
||||
@@ -158,6 +168,12 @@ 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"
|
||||
@@ -237,6 +253,35 @@ 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"
|
||||
|
||||
@@ -8,6 +8,10 @@ description = "Compiles a single Hyprland-idiom config file into the cosmic-conf
|
||||
[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"
|
||||
|
||||
@@ -630,7 +630,7 @@ fn render_local_rasi(icon_theme: Option<&str>, wallpaper_link: Option<&Path>) ->
|
||||
let mut out = String::from(
|
||||
r#"/* Per-machine launcher settings for HyprCosmic.
|
||||
*
|
||||
* Generated by `cosmic-conf import-theme --assets`. It is the last of the four
|
||||
* Generated by `hyprcosmic-conf import-theme --assets`. It is the last of the four
|
||||
* imports in config.rasi, so anything here wins; it is also overwritten by the
|
||||
* next import run with --overwrite, so keep hand edits somewhere else.
|
||||
*
|
||||
|
||||
@@ -272,9 +272,9 @@ fn render_conf(
|
||||
notes: &[Note],
|
||||
) -> String {
|
||||
let mut out = format!(
|
||||
"# Generated by `cosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\
|
||||
"# Generated by `hyprcosmic-conf import-theme` from the HyDE theme {theme_name:?}.\n\
|
||||
# Edit freely — this file is the source of truth; cosmic-settings changes\n\
|
||||
# are overwritten on the next `cosmic-conf apply`.\n"
|
||||
# are overwritten on the next `hyprcosmic-conf apply`.\n"
|
||||
);
|
||||
|
||||
let dropped: Vec<&Note> = notes
|
||||
|
||||
@@ -16,12 +16,16 @@ 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 {
|
||||
|
||||
@@ -7,14 +7,18 @@ 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 = "\
|
||||
cosmic-conf — compile cosmic.conf into the cosmic-config tree
|
||||
hyprcosmic-conf — compile cosmic.conf into the cosmic-config tree
|
||||
|
||||
USAGE:
|
||||
cosmic-conf apply [--diff] [--config <path>]
|
||||
cosmic-conf watch [--config <path>]
|
||||
cosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
|
||||
[--assets [--source <dir>] [--overwrite] [--dry-run]]
|
||||
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
|
||||
|
||||
+258
-3
@@ -15,6 +15,8 @@ use std::collections::BTreeMap;
|
||||
use crate::bind;
|
||||
use crate::parser::{Ast, Item, Span, Spanned};
|
||||
use crate::schema::{self, Entry, Range, Target, Ty};
|
||||
use crate::windowrule;
|
||||
use crate::workspace;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
@@ -268,12 +270,61 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
||||
let mut projected: BTreeMap<TargetKey, BTreeMap<Vec<String>, Value>> = BTreeMap::new();
|
||||
let mut whole: BTreeMap<TargetKey, (Value, Span)> = BTreeMap::new();
|
||||
|
||||
// `bind` is the one repeatable key in the language: many lines fold into a
|
||||
// single map rather than the last one winning, so it cannot go through the
|
||||
// schema, which is built around one conf key naming one value.
|
||||
// `bind`, `workspace` and `windowrule` are the repeatable keys in the
|
||||
// language: many lines fold into a single value rather than the last one
|
||||
// winning, so none of them can go through the schema, which is built around
|
||||
// one conf key naming one value.
|
||||
let mut binds: Vec<(bind::Bind, Span)> = Vec::new();
|
||||
let mut workspaces: Vec<(workspace::WorkspaceDecl, Span)> = Vec::new();
|
||||
let mut window_rules: Vec<windowrule::WindowRuleDecl> = Vec::new();
|
||||
|
||||
for (conf, raw_value, key_span) in &flat {
|
||||
// `windowrulev2` was Hyprland's name for this syntax before it became
|
||||
// the only one; configs in the wild are still full of it.
|
||||
if conf == "windowrule" || conf == "windowrulev2" {
|
||||
let expanded = expand_vars(&raw_value.value, &vars);
|
||||
// Not deduplicated: two rules can differ only in their title and
|
||||
// both be wanted, and the compositor takes the first that matches,
|
||||
// so the order they were written in is the whole semantics.
|
||||
match windowrule::parse_window_rule(&expanded, raw_value.span) {
|
||||
Ok(r) => window_rules.push(r),
|
||||
Err(e) => diags.push(Diagnostic {
|
||||
message: e.message,
|
||||
span: e.span,
|
||||
help: e.help,
|
||||
}),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if conf == "workspace" {
|
||||
let expanded = expand_vars(&raw_value.value, &vars);
|
||||
match workspace::parse_workspace(&expanded, raw_value.span) {
|
||||
Ok(w) => {
|
||||
if let Some((_, prev_span)) =
|
||||
workspaces.iter().find(|(o, _)| o.index == w.index)
|
||||
{
|
||||
diags.push(Diagnostic {
|
||||
message: format!("workspace {} is already declared", w.index),
|
||||
span: raw_value.span,
|
||||
help: Some(format!(
|
||||
"the earlier declaration is on line {}",
|
||||
prev_span.line
|
||||
)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
workspaces.push((w, raw_value.span));
|
||||
}
|
||||
Err(e) => diags.push(Diagnostic {
|
||||
message: e.message,
|
||||
span: e.span,
|
||||
help: e.help,
|
||||
}),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if conf == "bind" {
|
||||
let expanded = expand_vars(&raw_value.value, &vars);
|
||||
match bind::parse_bind(&expanded, raw_value.span) {
|
||||
@@ -339,6 +390,19 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
||||
return Err(diags);
|
||||
}
|
||||
|
||||
// A pinned workspace carries its own `tiling_enabled`, so one that did not
|
||||
// say has to inherit the session default rather than default to off --
|
||||
// otherwise declaring workspaces would quietly undo `general:autotile`.
|
||||
// Read before `whole` is consumed below.
|
||||
let default_tiling = whole
|
||||
.get(&TargetKey {
|
||||
component: "com.system76.CosmicComp".into(),
|
||||
version: 1,
|
||||
key: "autotile".into(),
|
||||
})
|
||||
.map(|(v, _)| *v == Value::Bool(true))
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut writes: Vec<Write> = whole
|
||||
.into_iter()
|
||||
.map(|(target, (v, _))| Write {
|
||||
@@ -367,6 +431,40 @@ pub fn resolve(ast: &Ast) -> Result<Resolved, Vec<Diagnostic>> {
|
||||
});
|
||||
}
|
||||
|
||||
if !workspaces.is_empty() {
|
||||
let rendered = workspace::render(
|
||||
&workspaces.iter().map(|(w, _)| w.clone()).collect::<Vec<_>>(),
|
||||
default_tiling,
|
||||
);
|
||||
writes.push(Write {
|
||||
// `Workspaces::add_output` drains this into the first output that
|
||||
// appears (cosmic-comp `shell/mod.rs`), and `from_pinned` sets
|
||||
// `pinned: true`, which is what stops `can_auto_remove` collecting
|
||||
// the workspace the moment its last window closes.
|
||||
target: TargetKey {
|
||||
component: "com.system76.CosmicComp".into(),
|
||||
version: 1,
|
||||
key: "pinned_workspaces".into(),
|
||||
},
|
||||
kind: WriteKind::Verbatim(rendered),
|
||||
});
|
||||
}
|
||||
|
||||
if !window_rules.is_empty() {
|
||||
let rendered = windowrule::render(&window_rules);
|
||||
writes.push(Write {
|
||||
// Read live: cosmic-comp's config watcher has a `window_rules` arm,
|
||||
// so an edit applies to the next window that opens. Unlike
|
||||
// `pinned_workspaces`, which waits for the next login.
|
||||
target: TargetKey {
|
||||
component: "com.system76.CosmicComp".into(),
|
||||
version: 1,
|
||||
key: "window_rules".into(),
|
||||
},
|
||||
kind: WriteKind::Verbatim(rendered),
|
||||
});
|
||||
}
|
||||
|
||||
writes.sort_by(|a, b| a.target.cmp(&b.target));
|
||||
Ok(Resolved { writes })
|
||||
}
|
||||
@@ -510,6 +608,163 @@ mod tests {
|
||||
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspaces_fold_into_one_write_against_the_comp_pinned_workspaces_key() {
|
||||
let r = resolved("workspace = 1, name:term\nworkspace = 2, name:web\n");
|
||||
let w: Vec<_> = r
|
||||
.writes
|
||||
.iter()
|
||||
.filter(|w| w.target.key == "pinned_workspaces")
|
||||
.collect();
|
||||
assert_eq!(w.len(), 1, "every workspace belongs to one list");
|
||||
assert_eq!(w[0].target.component, "com.system76.CosmicComp");
|
||||
assert_eq!(w[0].target.version, 1);
|
||||
|
||||
let WriteKind::Verbatim(ron) = &w[0].kind else {
|
||||
panic!("expected verbatim RON, got {:?}", w[0].kind);
|
||||
};
|
||||
assert!(ron.contains(r#"name: Some("term")"#), "{ron}");
|
||||
assert!(ron.contains(r#"name: Some("web")"#), "{ron}");
|
||||
}
|
||||
|
||||
/// Without this the pinned workspaces would come back floating for a user
|
||||
/// whose whole reason for editing the file was `autotile = true`.
|
||||
#[test]
|
||||
fn a_workspace_that_did_not_say_inherits_general_autotile() {
|
||||
let r = resolved("general {\n autotile = true\n}\nworkspace = 1\n");
|
||||
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
|
||||
else {
|
||||
panic!("expected verbatim RON");
|
||||
};
|
||||
assert!(ron.contains("tiling_enabled: true"), "{ron}");
|
||||
|
||||
let r = resolved("general {\n autotile = false\n}\nworkspace = 1\n");
|
||||
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
|
||||
else {
|
||||
panic!("expected verbatim RON");
|
||||
};
|
||||
assert!(ron.contains("tiling_enabled: false"), "{ron}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaring_the_same_workspace_twice_is_an_error_not_a_silent_overwrite() {
|
||||
let d = errors("workspace = 2, name:web\nworkspace = 2, name:mail\n");
|
||||
assert_eq!(d.len(), 1);
|
||||
assert!(
|
||||
d[0].message.contains("already declared"),
|
||||
"{}",
|
||||
d[0].message
|
||||
);
|
||||
assert!(
|
||||
d[0].help.as_ref().unwrap().contains("line 1"),
|
||||
"{:?}",
|
||||
d[0].help
|
||||
);
|
||||
}
|
||||
|
||||
/// Symmetric with `no_binds_means_the_shortcuts_file_is_left_alone`: writing
|
||||
/// an empty list would unpin the workspaces of anyone whose cosmic.conf
|
||||
/// simply does not mention them.
|
||||
#[test]
|
||||
fn no_workspaces_means_the_pinned_list_is_left_alone() {
|
||||
let r = resolved("general {\n gaps_in = 4\n}\n");
|
||||
assert!(r.writes.iter().all(|w| w.target.key != "pinned_workspaces"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workspace_expands_variables() {
|
||||
let r = resolved("$browser = web\nworkspace = 1, name:$browser\n");
|
||||
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "pinned_workspaces")
|
||||
else {
|
||||
panic!("expected verbatim RON");
|
||||
};
|
||||
assert!(ron.contains(r#"name: Some("web")"#), "{ron}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_workspace_is_reported_with_the_rest_of_the_file() {
|
||||
let d = errors("workspace = 0\ngeneral {\n gaps_inn = 8\n}\n");
|
||||
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_rules_fold_into_one_write_against_the_comp_window_rules_key() {
|
||||
let r = resolved(
|
||||
"windowrule = workspace name:web, class:^(vivaldi)$\n\
|
||||
windowrule = workspace 1, class:^(kitty)$\n",
|
||||
);
|
||||
let w: Vec<_> = r
|
||||
.writes
|
||||
.iter()
|
||||
.filter(|w| w.target.key == "window_rules")
|
||||
.collect();
|
||||
assert_eq!(w.len(), 1, "every rule belongs to one list");
|
||||
assert_eq!(w[0].target.component, "com.system76.CosmicComp");
|
||||
assert_eq!(w[0].target.version, 1);
|
||||
|
||||
let WriteKind::Verbatim(ron) = &w[0].kind else {
|
||||
panic!("expected verbatim RON, got {:?}", w[0].kind);
|
||||
};
|
||||
assert!(ron.contains(r#"workspace: Name("web")"#), "{ron}");
|
||||
assert!(ron.contains("workspace: Index(1)"), "{ron}");
|
||||
}
|
||||
|
||||
/// The compositor takes the first rule that matches, so a file that reads
|
||||
/// top to bottom has to be emitted top to bottom.
|
||||
#[test]
|
||||
fn window_rules_keep_the_order_they_were_written_in() {
|
||||
let r = resolved(
|
||||
"windowrule = workspace 1, class:^(a)$\n\
|
||||
windowrule = workspace 2, class:^(b)$\n\
|
||||
windowrule = workspace 3, class:^(c)$\n",
|
||||
);
|
||||
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
|
||||
panic!("expected verbatim RON");
|
||||
};
|
||||
let seen: Vec<&str> = ron
|
||||
.lines()
|
||||
.filter(|l| l.contains("app_id:"))
|
||||
.map(|l| l.trim())
|
||||
.collect();
|
||||
assert_eq!(seen.len(), 3);
|
||||
assert!(seen[0].contains("^(a)$"), "{ron}");
|
||||
assert!(seen[1].contains("^(b)$"), "{ron}");
|
||||
assert!(seen[2].contains("^(c)$"), "{ron}");
|
||||
}
|
||||
|
||||
/// The v2 spelling is what configs in the wild are written with.
|
||||
#[test]
|
||||
fn windowrulev2_is_the_same_key() {
|
||||
let r = resolved("windowrulev2 = workspace 2, class:^(firefox)$\n");
|
||||
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
|
||||
panic!("expected verbatim RON");
|
||||
};
|
||||
assert!(ron.contains("workspace: Index(2)"), "{ron}");
|
||||
}
|
||||
|
||||
/// Symmetric with the bind and workspace cases: writing an empty list would
|
||||
/// be a change for someone whose cosmic.conf never mentions window rules.
|
||||
#[test]
|
||||
fn no_window_rules_means_the_list_is_left_alone() {
|
||||
let r = resolved("general {\n gaps_in = 4\n}\n");
|
||||
assert!(r.writes.iter().all(|w| w.target.key != "window_rules"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_rule_expands_variables() {
|
||||
let r = resolved("$browser = vivaldi\nwindowrule = workspace 2, class:^($browser)$\n");
|
||||
let WriteKind::Verbatim(ron) = find(&r, "com.system76.CosmicComp", "window_rules") else {
|
||||
panic!("expected verbatim RON");
|
||||
};
|
||||
assert!(ron.contains("^(vivaldi)$"), "{ron}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_window_rule_is_reported_with_the_rest_of_the_file() {
|
||||
let d = errors("windowrule = float, class:foo\ngeneral {\n gaps_inn = 8\n}\n");
|
||||
assert_eq!(d.len(), 2, "resolve reports everything in one pass: {d:?}");
|
||||
}
|
||||
|
||||
fn find<'a>(r: &'a Resolved, component: &str, key: &str) -> &'a WriteKind {
|
||||
&r.writes
|
||||
.iter()
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule cosmic-session updated: 73867a5d6a...75267c5977
+27
-20
@@ -19,10 +19,14 @@ arch=('x86_64')
|
||||
url="https://github.com/outbackdingo/hyprcosmic"
|
||||
license=('GPL-3.0-only')
|
||||
|
||||
# The HyDE shell. Without these the session starts to a blank screen: no bar,
|
||||
# no launcher, no wallpaper.
|
||||
depends=('waybar' 'rofi-wayland' 'wayland' 'libxkbcommon' 'libinput' 'seatd'
|
||||
'mesa' 'pixman' 'libdisplay-info' 'systemd-libs')
|
||||
# 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
|
||||
@@ -32,29 +36,32 @@ optdepends=('awww: wallpaper daemon, required for HyDE theme wallpapers'
|
||||
'ttf-nerd-fonts-symbols: glyphs the waybar config draws with'
|
||||
'qt5ct: Qt application theming to match the GTK theme')
|
||||
|
||||
# What "complete replacement" means in packaging terms. Every path this writes
|
||||
# under /usr/bin is one cosmic-comp and cosmic-session also own, so the two
|
||||
# cannot coexist -- which is correct, they are two builds of the same programs.
|
||||
#
|
||||
# conflicts without replaces/provides-driven auto-removal: pacman stops and
|
||||
# names the conflict rather than quietly removing the desktop the machine is
|
||||
# currently running.
|
||||
conflicts=('cosmic-comp' 'cosmic-session')
|
||||
provides=("cosmic-comp=$pkgver" "cosmic-session=$pkgver")
|
||||
# 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() {
|
||||
# Set by make-packages.sh. Failing loudly here beats producing an empty
|
||||
# package, which is what a bare `cp -a "$unset/."` would do.
|
||||
# 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 tools/make-packages.sh" >&2
|
||||
echo "HYPRCOSMIC_STAGEDIR unset or has no usr/; see .github/workflows/packages.yml" >&2
|
||||
return 1
|
||||
fi
|
||||
cp -a "$HYPRCOSMIC_STAGEDIR/." "$pkgdir/"
|
||||
|
||||
# The two files a broken install shows up in first: a bad Exec line puts an
|
||||
# entry on the greeter's menu that fails silently when it is chosen.
|
||||
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/hyprcosmic.desktop"
|
||||
desktop-file-validate "$pkgdir/usr/share/wayland-sessions/cosmic.desktop"
|
||||
# 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.
|
||||
}
|
||||
|
||||
@@ -9,16 +9,13 @@ Installed-Size: @INSTALLED_SIZE@
|
||||
Depends: @SHLIB_DEPENDS@
|
||||
Recommends: waybar, rofi
|
||||
Suggests: fonts-hack-ttf, qt5ct
|
||||
Conflicts: cosmic-comp, cosmic-session
|
||||
Provides: cosmic-comp, cosmic-session
|
||||
Replaces: cosmic-comp, cosmic-session
|
||||
Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
|
||||
HyprCosmic is a fork of the COSMIC desktop that takes its configuration in
|
||||
Hyprland's idiom and wears a HyDE-style shell.
|
||||
.
|
||||
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
||||
lines and $variables -- is compiled into COSMIC's own configuration tree by
|
||||
cosmic-conf. The file is the source of truth: keys it names are applied at
|
||||
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.
|
||||
.
|
||||
@@ -27,6 +24,9 @@ Description: COSMIC configured in Hyprland's idiom, with a HyDE shell
|
||||
COSMIC's, with a Hyprland-compatible IPC socket so that HyDE's scripts and
|
||||
waybar's hyprland modules work unmodified.
|
||||
.
|
||||
This package replaces the distribution's COSMIC. It installs both session
|
||||
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one,
|
||||
both served by these binaries.
|
||||
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.
|
||||
|
||||
@@ -20,14 +20,19 @@
|
||||
# it on the release you intend to install it on. The workflow does that by
|
||||
# running the whole job inside a container of the target distribution.
|
||||
#
|
||||
# WHY IT IS ONE PACKAGE AND NOT TWENTY-SEVEN
|
||||
# ------------------------------------------
|
||||
# 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 is a fork that replaces the desktop as a
|
||||
# unit: the compositor, the session and the config compiler are versioned and
|
||||
# tested together, and there is no supported combination in which you take the
|
||||
# HyprCosmic cosmic-comp and the distribution's cosmic-session. One package is
|
||||
# an accurate description of what is actually supported.
|
||||
# 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
|
||||
|
||||
@@ -45,22 +50,22 @@ License: GPL-3.0-only
|
||||
URL: https://github.com/outbackdingo/hyprcosmic
|
||||
BuildArch: x86_64
|
||||
|
||||
# These are what "complete replacement" means in packaging terms. Every path
|
||||
# this package writes under /usr/bin and /usr/share/cosmic is owned by one of
|
||||
# these on a stock Fedora, so the two cannot be installed at once -- which is
|
||||
# correct, because they are two builds of the same programs.
|
||||
# COSMIC itself, which this runs on rather than replaces.
|
||||
#
|
||||
# Conflicts rather than Obsoletes, deliberately. Obsoletes would let a routine
|
||||
# `dnf install hyprcosmic` quietly remove the desktop the machine is currently
|
||||
# running. Conflicts stops and says so, and removing the COSMIC packages stays
|
||||
# something a person decides to do rather than something a resolver does on
|
||||
# their behalf.
|
||||
Conflicts: cosmic-comp
|
||||
Conflicts: cosmic-session
|
||||
|
||||
# What it stands in for, so anything depending on a COSMIC session is satisfied.
|
||||
Provides: cosmic-comp = %{version}-%{release}
|
||||
Provides: cosmic-session = %{version}-%{release}
|
||||
# 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
|
||||
@@ -76,8 +81,30 @@ Requires: rofi-wayland
|
||||
# unsatisfiable dependency is not.
|
||||
Recommends: awww
|
||||
|
||||
# Nerd Font glyphs are most of what the bar draws.
|
||||
Recommends: nerd-fonts
|
||||
# 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
|
||||
@@ -85,7 +112,7 @@ Hyprland's idiom and wears a HyDE-style shell.
|
||||
|
||||
A single ~/.config/hyprcosmic/cosmic.conf -- with general { } blocks, bind =
|
||||
lines and $variables -- is compiled into COSMIC's own configuration tree by
|
||||
cosmic-conf. The file is the source of truth: keys it names are applied at every
|
||||
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.
|
||||
|
||||
@@ -94,9 +121,11 @@ cosmic-bg, and HyDE themes are imported directly. The compositor is COSMIC's,
|
||||
with a Hyprland-compatible IPC socket so that HyDE's scripts and waybar's
|
||||
hyprland modules work unmodified.
|
||||
|
||||
This package replaces the distribution's COSMIC. It installs both session
|
||||
entries, so the greeter offers a stock COSMIC shell as well as the HyDE one,
|
||||
both served by these binaries.
|
||||
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.
|
||||
@@ -106,11 +135,16 @@ test -n "%{stagedir}" || { echo "define stagedir: see .github/workflows/packages
|
||||
test -d "%{stagedir}/usr" || { echo "%{stagedir}/usr missing; run just install first" >&2; exit 1; }
|
||||
cp -a "%{stagedir}/." "%{buildroot}/"
|
||||
|
||||
# The desktop entries are the two files a broken install shows up in first, so
|
||||
# they are validated rather than assumed. A .desktop with a bad Exec line puts
|
||||
# an entry on the greeter's menu that fails silently when chosen.
|
||||
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/hyprcosmic.desktop"
|
||||
desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/cosmic.desktop"
|
||||
# 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
|
||||
@@ -119,5 +153,15 @@ desktop-file-validate "%{buildroot}%{_datadir}/wayland-sessions/cosmic.desktop"
|
||||
%files -f %{filelist}
|
||||
|
||||
%changelog
|
||||
* Sun Aug 10 2026 dingo <[email protected]> - 0.1.0-1
|
||||
- First package of the fork: COSMIC replaced as a unit, HyDE shell, cosmic-conf.
|
||||
* 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.
|
||||
|
||||
+46
-16
@@ -76,6 +76,9 @@ SHARED=(
|
||||
"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
|
||||
@@ -95,31 +98,57 @@ SOURCES=(
|
||||
"config/waybar/generate-config.py" # the generator, and the icon table
|
||||
)
|
||||
|
||||
# Files under config/ that are deliberately NOT installed here, each with the
|
||||
# thing that does install it. This list is not decoration: the audit below
|
||||
# refuses to run unless every file under config/ appears in exactly one of the
|
||||
# three lists, so adding a file forces a decision about where it belongs instead
|
||||
# of letting it be quietly left out of all of them.
|
||||
PER_USER=(
|
||||
"config/autostart" # ~/.config/hyprcosmic/autostart, by hand
|
||||
"config/cosmic.conf" # ~/.config/hyprcosmic/cosmic.conf, by hand
|
||||
"config/waybar/style.css" # ~/.config/hyprcosmic/waybar/style.css; @imports a sibling theme.css
|
||||
"config/rofi/config.rasi" # ~/.config/rofi/config.rasi, by `cosmic-conf import-theme --assets`
|
||||
# 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=" ${PER_USER[*]} ${SOURCES[*]} " unclassified=()
|
||||
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")
|
||||
done < <(find "$REPO/config" -type f -print0 | sort -z)
|
||||
# Dot-directories are pruned. Nothing shipped lives in one, and tooling
|
||||
# drops state inside the tree without asking -- .omc/ appeared under
|
||||
# config/waybar/ and failed this audit with six files that are gitignored
|
||||
# and are not assets. Failing on those trains you to ignore the one message
|
||||
# that catches a genuinely unclassified file. Dot *files* are still walked;
|
||||
# only directories are pruned.
|
||||
done < <(find "$REPO/config" -name '.?*' -type d -prune -o -type f -print0 | sort -z)
|
||||
|
||||
((${#unclassified[@]} == 0)) || die "not listed as shared or per-user: ${unclassified[*]}
|
||||
Add each to SHARED or PER_USER in $(basename "${BASH_SOURCE[0]}") and say which."
|
||||
((${#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
|
||||
@@ -130,11 +159,12 @@ audit_config_tree() {
|
||||
check_prefix_assumptions() {
|
||||
[[ "$PREFIX" == /usr ]] && return 0
|
||||
local hits
|
||||
hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ 2>/dev/null | sort | tr '\n' ' ')"
|
||||
hits="$(cd "$REPO" && grep -rl '/usr/share/hyprcosmic' config/ cosmic-session/data/ 2>/dev/null | sort | tr '\n' ' ')"
|
||||
[[ -z "$hits" ]] && return 0
|
||||
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
|
||||
@@ -190,7 +220,7 @@ handle() {
|
||||
audit_config_tree
|
||||
[[ "$MODE" == install ]] && check_prefix_assumptions
|
||||
|
||||
targets=("${SHARED[@]}")
|
||||
targets=("${SHARED[@]}" "${SKEL[@]}")
|
||||
if ((WITH_SESSION)); then
|
||||
if [[ -d "$REPO/cosmic-session/data" ]]; then
|
||||
targets+=("${SESSION[@]}")
|
||||
|
||||
Reference in New Issue
Block a user