Install HyDE themes end to end

Three things stood between `assets.rs` and a themed desktop.

`import-theme` never called it. The module was written, tested and unreachable;
`--assets` now wires it up, with `--source`, `--overwrite` and `--dry-run`, and
finds the theme repo's Source/ directory by searching upward rather than
assuming HyDE's exact nesting depth.

The archive guard rejected every real icon theme. Refusing any `..` in a link
target is right for an entry path but wrong for a symlink: icon themes are
built out of relative links into sibling directories, and Tela ships thousands
of `../devices/network-wireless.svg`. What matters is whether the target
resolves inside the destination, which `stays_within_root` now decides
lexically -- no canonicalize, since the tree does not exist at plan time and
following real links during validation would be a TOCTOU window. Absolute
targets and links that climb past the root are still refused; the existing
escape tests still pass.

`apply` silently ignored `source`. It parsed and resolved inline while `watch`
went through `compile`, and `flatten` drops `Item::Source` -- so an include
that worked under `watch` vanished under `apply`. `apply` now uses `compile`
too. This matters immediately: the generated theme lives in its own
theme.conf, sourced from cosmic.conf, so re-importing a theme cannot clobber
the keybindings.

The waybar stylesheet claimed a theme could be dropped in ahead of it to
recolour the bar. It could not -- HyDE names its colours main-bg/wb-act-bg and
the rules referenced bar-bg/accent. Split into palette + theme + bridge +
rules, imported in that order, so the claim is now true. Verified by loading
the result through GTK's own CSS parser: with Tokyo Night installed main-bg
resolves to #24283b and wb-act-bg to #bb9af7; with an empty theme.css the
defaults stand. Both parse without error.

Two deliberate departures, both commented where they are made: the theme's
near-transparent bar-bg is composited at 0.85 because cosmic-comp has no blur
to put behind it, and theme.css is copied next to style.css rather than
imported from HyDE's own path, because a missing @import is fatal in GTK and
would break the bar on any machine without a theme.
This commit is contained in:
2026-08-10 09:11:08 +07:00
parent 10c5cc73d4
commit e46514cbab
7 changed files with 387 additions and 107 deletions
+8 -4
View File
@@ -7,10 +7,14 @@
# `#` starts a comment where a word would start, so `--color=#1a1b26` is fine
# but `--color #1a1b26` is not; quote it as '#1a1b26' if you need the latter.
# The bar. Configs live under /usr/share/hyprcosmic so this file needs no
# per-user paths; copy them into ~/.config/hyprcosmic/waybar and point here to
# customise.
waybar -c /usr/share/hyprcosmic/waybar/config.jsonc -s /usr/share/hyprcosmic/waybar/style.css
# The bar. The layout is shared and lives under /usr/share, but the stylesheet
# has to be per-user: it imports a sibling theme.css holding the installed HyDE
# theme's palette, and a relative @import resolves against the importing file.
#
# The style path below is therefore absolute and contains a home directory.
# This file is not a shell, so `~` and `$HOME` are literal text here -- replace
# the path with your own if you are copying this template.
waybar -c /usr/share/hyprcosmic/waybar/config.jsonc -s /home/dingo/.config/hyprcosmic/waybar/style.css
# rofi is not a daemon. It is launched on demand by a keybinding, which COSMIC
# stores in com.system76.CosmicSettings.Shortcuts rather than here. Set those
+26
View File
@@ -0,0 +1,26 @@
/* Map HyDE's waybar colour names onto the ones rules.css uses.
*
* Imported after both palette.css and the theme's own waybar.theme, so it sees
* whichever definition of each HyDE name is in force and does not care which
* file supplied it. This indirection is the whole reason a HyDE theme can
* recolour this bar without any of the rules changing.
*/
@define-color bar-fg @main-fg;
@define-color accent @wb-act-bg;
@define-color muted @wb-hvr-fg;
/* `bar-bg` IS remapped, and this is a deliberate departure from the theme.
*
* HyDE themes set bar-bg to something like rgba(0, 0, 0, 0.1) and rely on the
* compositor blurring whatever is behind the bar. cosmic-comp has no
* rule-driven blur -- `import-theme --report` lists decoration.blur.* as
* needing a compositor patch -- so honouring that value literally gives a
* ~90% transparent bar with unblurred desktop showing through and text that
* cannot be read.
*
* So the theme's own background colour is used at an opacity that works
* without blur. Delete these two lines to get the theme's literal value back,
* or once blur lands.
*/
@define-color bar-bg alpha(@main-bg, 0.85);
+33
View File
@@ -0,0 +1,33 @@
/* Default colours for the HyprCosmic bar.
*
* Two name sets are defined here, and both matter:
*
* - `main-bg`, `wb-act-bg` and friends are HyDE's names. A HyDE theme's
* waybar.theme is nothing but a list of these, so defining them here means
* a theme file can override them by being imported after this one.
* - `bar-bg`, `accent` and friends are the names rules.css actually uses.
*
* bridge-hyde.css maps the first set onto the second. Defining HyDE's names
* here as well is what makes a missing theme file harmless: the bridge always
* has something to resolve against, so an unthemed bar renders with these
* values instead of failing to parse.
*
* Values are Tokyo Night, matching cosmic-conf's own defaults.
*/
/* HyDE's names. Overridden by ~/.config/waybar/theme.css when a theme is in. */
@define-color main-bg #1a1b26;
@define-color main-fg #c0caf5;
@define-color wb-act-bg #7aa2f7;
@define-color wb-act-fg #1a1b26;
@define-color wb-hvr-bg #7aa2f7;
@define-color wb-hvr-fg #565f89;
/* Names with no HyDE equivalent, so never themed and always these values. */
@define-color warning #e0af68;
@define-color critical #f7768e;
/* The bar's own background. HyDE themes define this one under the same name
* we use, and typically as a low-alpha rgba() so the compositor's blur shows
* through. */
@define-color bar-bg rgba(26, 27, 38, 0.85);
+63
View File
@@ -0,0 +1,63 @@
/* Geometry and layout for the HyprCosmic bar.
*
* No colour literals: every colour here is a name defined by palette.css and
* possibly re-pointed by bridge-hyde.css. That separation is what lets a theme
* change the palette without touching a single rule.
*/
* {
font-family: "FontAwesome 6 Free", "Noto Sans", sans-serif;
font-size: 13px;
border: none;
border-radius: 0;
min-height: 0;
}
window#waybar {
background: @bar-bg;
color: @bar-fg;
}
#workspaces button {
padding: 0 8px;
color: @muted;
background: transparent;
}
#workspaces button.active {
color: @bar-fg;
box-shadow: inset 0 -2px @accent;
}
#workspaces button:hover {
color: @wb-hvr-fg;
background: @wb-hvr-bg;
}
#taskbar button {
padding: 0 6px;
background: transparent;
}
#taskbar button.active {
box-shadow: inset 0 -2px @accent;
}
#clock,
#pulseaudio,
#network,
#cpu,
#memory,
#battery,
#tray {
padding: 0 10px;
}
#clock {
font-weight: bold;
}
#battery.warning { color: @warning; }
#battery.critical { color: @critical; }
#network.disconnected { color: @critical; }
#pulseaudio.muted { color: @muted; }
+27 -69
View File
@@ -1,74 +1,32 @@
/* Waybar styling for a HyprCosmic session.
*
* Deliberately plain. HyDE's own bar styling is bespoke CSS belonging to HyDE
* rather than to any theme: a HyDE theme's waybar.theme file is only a short
* list of @define-color declarations. So the palette below is what a theme
* import can eventually overwrite, and the geometry is what stays put.
* This file is only an import list, because the order is the whole design:
*
* Keep every colour reference pointing at one of these names, so that dropping
* a theme's waybar.theme in ahead of this file is enough to re-colour the bar.
* 1. palette.css default colours, under both our names and HyDE's
* 2. theme.css the installed HyDE theme's waybar.theme, if any
* 3. bridge-hyde.css maps HyDE's colour names onto the ones the rules use
* 4. rules.css geometry and layout; no colour literals at all
*
* Every entry is an @import, so they are read strictly in sequence and a later
* definition of a colour wins over an earlier one. That is what lets step 2
* recolour the bar without step 4 knowing a theme exists.
*
* Step 2 is a copy of the installed theme's waybar.theme, kept as a sibling of
* this file rather than read from HyDE's own ~/.config/waybar/theme.css.
*
* That copy exists because a missing @import is fatal in GTK, not a warning:
* pointing at HyDE's path directly means the whole stylesheet fails to load on
* any machine where no theme has been imported. A sibling file we create at
* install time is always present -- empty when there is no theme, in which
* case the defaults from step 1 stand. palette.css defines HyDE's names too,
* so the bridge in step 3 resolves either way.
*
* Consequently this file belongs at ~/.config/hyprcosmic/waybar/style.css, not
* under /usr/share: a relative @import resolves against the importing file,
* and theme.css is per-user.
*/
@define-color bar-bg rgba(26, 27, 38, 0.85);
@define-color bar-fg #c0caf5;
@define-color accent #7aa2f7;
@define-color warning #e0af68;
@define-color critical #f7768e;
@define-color muted #565f89;
* {
font-family: "FontAwesome 6 Free", "Noto Sans", sans-serif;
font-size: 13px;
border: none;
border-radius: 0;
min-height: 0;
}
window#waybar {
background: @bar-bg;
color: @bar-fg;
}
#workspaces button {
padding: 0 8px;
color: @muted;
background: transparent;
}
#workspaces button.active {
color: @bar-fg;
box-shadow: inset 0 -2px @accent;
}
#workspaces button:hover {
color: @bar-fg;
background: rgba(122, 162, 247, 0.15);
}
#taskbar button {
padding: 0 6px;
background: transparent;
}
#taskbar button.active {
box-shadow: inset 0 -2px @accent;
}
#clock,
#pulseaudio,
#network,
#cpu,
#memory,
#battery,
#tray {
padding: 0 10px;
}
#clock {
font-weight: bold;
}
#battery.warning { color: @warning; }
#battery.critical { color: @critical; }
#network.disconnected { color: @critical; }
#pulseaudio.muted { color: @muted; }
@import url("file:///usr/share/hyprcosmic/waybar/palette.css");
@import url("theme.css");
@import url("file:///usr/share/hyprcosmic/waybar/bridge-hyde.css");
@import url("file:///usr/share/hyprcosmic/waybar/rules.css");
+148 -4
View File
@@ -158,6 +158,56 @@ pub struct Report {
pub installed: Vec<PathBuf>,
}
impl Action {
pub fn kind(&self) -> AssetKind {
match self {
Action::ExtractArchive { kind, .. } | Action::CopyVerbatim { kind, .. } => *kind,
Action::CopyWallpaper { .. } => AssetKind::Wallpaper,
}
}
pub fn dest(&self) -> &Path {
match self {
Action::ExtractArchive { dest, .. }
| Action::CopyWallpaper { dest, .. }
| Action::CopyVerbatim { dest, .. } => dest,
}
}
}
/// What `apply` would do, for `--dry-run`.
///
/// Deliberately not `render_report` with a synthesised `Report`: saying
/// "Installed:" about files that were never written is the kind of small lie
/// that makes a tool untrustworthy.
pub fn render_plan(plan: &Plan) -> String {
let mut out = String::new();
if plan.actions.is_empty() {
out.push_str("Nothing to install.\n");
} else {
out.push_str("Would install:\n");
for a in &plan.actions {
out.push_str(&format!(
" {} ({})\n",
a.dest().display(),
a.kind().label()
));
}
}
if !plan.skipped.is_empty() {
out.push_str("\nWould skip:\n");
for n in &plan.skipped {
out.push_str(&format!(
" {} ({}): {}\n",
n.path.display(),
n.kind.label(),
n.reason.describe()
));
}
}
out
}
/// Human-readable summary, in the same spirit as `import::render_report`:
/// nothing that was skipped is left unmentioned.
pub fn render_report(plan: &Plan, report: &Report) -> String {
@@ -482,11 +532,30 @@ fn walk_archive(archive_path: &Path, dest_root: &Path, write: bool) -> Result<Ve
// A symlink/hardlink's own entry path can be safe while its target
// still points outside `dest_root`; a later entry written "through"
// that link would then land wherever the link points. Same rule,
// applied to the target.
if matches!(entry.header().entry_type(), tar::EntryType::Symlink | tar::EntryType::Link) {
// that link would then land wherever the link points.
//
// The target cannot use the same rule as the entry path, though. Icon
// themes are built almost entirely out of relative symlinks pointing
// at sibling directories -- Tela ships thousands of
// `../devices/network-wireless.svg` -- so rejecting every `..` would
// reject every real icon theme. What matters is not whether the target
// contains `..` but whether it still lands inside `dest_root` once
// resolved, which is what `stays_within_root` decides.
let entry_type = entry.header().entry_type();
if matches!(entry_type, tar::EntryType::Symlink | tar::EntryType::Link) {
if let Some(target) = entry.link_name()? {
reject_unsafe_path(archive_path, &target)?;
// tar resolves a symlink target against the link's own
// directory, but a hardlink target against the archive root.
let base = match entry_type {
tar::EntryType::Link => Path::new(""),
_ => rel.parent().unwrap_or(Path::new("")),
};
if !stays_within_root(base, &target) {
return Err(AssetError::UnsafeArchiveEntry {
archive: archive_path.to_path_buf(),
entry: target.into_owned(),
});
}
}
}
@@ -519,6 +588,35 @@ fn reject_unsafe_path(archive: &Path, entry: &Path) -> Result<(), AssetError> {
Ok(())
}
/// Does `base/target` still land inside the root it started from?
///
/// Resolution is lexical on purpose. At plan time the destination tree does
/// not exist yet, so `canonicalize` has nothing to work with; and following
/// real symlinks during validation would open a TOCTOU window between the
/// check and the extraction. Counting depth over the joined components
/// answers the only question that matters without touching the filesystem.
fn stays_within_root(base: &Path, target: &Path) -> bool {
if target.is_absolute() {
return false;
}
let mut depth: isize = 0;
for c in base.components().chain(target.components()) {
match c {
Component::Normal(_) => depth += 1,
Component::ParentDir => {
depth -= 1;
if depth < 0 {
return false;
}
}
Component::CurDir => {}
// An absolute component anywhere replaces everything before it.
Component::RootDir | Component::Prefix(_) => return false,
}
}
true
}
/// The first path component of every entry, skipping a leading `./` — used
/// only as a best-effort "is this archive already installed?" heuristic
/// (real GTK/icon tarballs unpack into a single named directory), not as a
@@ -541,6 +639,52 @@ mod tests {
use flate2::Compression;
use tempfile::TempDir;
#[test]
fn a_relative_symlink_into_a_sibling_directory_is_allowed() {
// Regression: rejecting every `..` in a link target rejected every
// real icon theme. Tela ships thousands of exactly this shape, and
// Tokyo-Night's Icon_TelaPurple.tar.gz would not extract.
assert!(stays_within_root(
Path::new("Tela-purple-dark/16/panel"),
Path::new("../devices/network-wireless.svg")
));
assert!(stays_within_root(
Path::new("Tela/22/apps"),
Path::new("../../16/apps/firefox.svg")
));
}
#[test]
fn a_relative_symlink_that_climbs_past_the_root_is_still_refused() {
// One `..` too many is the whole attack, so the boundary is exact
// rather than approximate.
assert!(!stays_within_root(
Path::new("Tela/16/panel"),
Path::new("../../../../etc/passwd")
));
assert!(!stays_within_root(Path::new(""), Path::new("../escape")));
assert!(!stays_within_root(Path::new("a"), Path::new("../../escape")));
// Exactly back to the root is fine; one further is not.
assert!(stays_within_root(Path::new("a/b"), Path::new("../../c")));
assert!(!stays_within_root(Path::new("a/b"), Path::new("../../../c")));
}
#[test]
fn an_absolute_symlink_target_is_refused_however_it_is_spelled() {
assert!(!stays_within_root(Path::new("a/b"), Path::new("/etc/passwd")));
assert!(!stays_within_root(Path::new("a/b"), Path::new("/")));
}
#[test]
fn detours_that_end_up_back_inside_are_allowed() {
// `a/b/../c` never leaves, so refusing it would be strictness with no
// security value.
assert!(stays_within_root(
Path::new("theme/scalable"),
Path::new("../scalable/./places/../apps/icon.svg")
));
}
/// Build a `.tar.gz` fixture programmatically so tests do not depend on
/// binary blobs checked into the repo.
fn make_tarball(dir: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
+82 -30
View File
@@ -2,10 +2,10 @@
//!
//! Exit codes: 0 success, 1 config error (nothing written), 2 usage error.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use cosmic_conf::{emit::Emitter, import, parse, render_diagnostic, resolve};
use cosmic_conf::{assets, emit::Emitter, import, render_diagnostic, watch};
const USAGE: &str = "\
cosmic-conf — compile cosmic.conf into the cosmic-config tree
@@ -13,15 +13,35 @@ cosmic-conf — compile cosmic.conf into the cosmic-config tree
USAGE:
cosmic-conf apply [--diff] [--config <path>]
cosmic-conf import-theme <hypr.theme> [--out <path>] [--report]
[--assets [--source <dir>] [--overwrite] [--dry-run]]
OPTIONS:
--diff Show what would change without writing anything
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
--out <path> Write the generated cosmic.conf here (default: stdout)
--report Print everything that did not translate cleanly
--assets Also install wallpapers, GTK/icon themes and the
waybar/rofi/kitty theme files that sit beside hypr.theme
--source <dir> The theme repo's Source/ directory holding the GTK and
icon tarballs (default: found by searching upward)
--overwrite Replace assets that are already installed
--dry-run With --assets, list what would be installed and stop
-h, --help Show this help
";
/// HyDE keeps GTK and icon tarballs in a `Source/` directory at the root of
/// the theme repo, four levels above the theme folder
/// (`Configs/.config/hyde/themes/<Name>/`). Searching upward rather than
/// hardcoding that depth means a theme unpacked at a different depth, or one
/// vendored into another tree, still works.
fn find_source_dir(theme_dir: &std::path::Path) -> Option<PathBuf> {
theme_dir
.ancestors()
.take(6)
.map(|a| a.join("Source"))
.find(|c| c.is_dir())
}
fn default_config_path() -> Option<PathBuf> {
let base = match std::env::var_os("XDG_CONFIG_HOME") {
Some(x) if !x.is_empty() => PathBuf::from(x),
@@ -85,35 +105,16 @@ fn main() -> ExitCode {
}
fn run(config_path: &PathBuf, diff_only: bool) -> Result<String, String> {
let source = std::fs::read_to_string(config_path)
.map_err(|e| format!("error: cannot read {}: {e}\n", config_path.display()))?;
let ast = parse(&source).map_err(|e| {
render_diagnostic(&source, e.span, &e.message, None)
})?;
let resolved = resolve(&ast).map_err(|diags| {
let mut out = String::new();
for d in &diags {
out.push_str(&render_diagnostic(&source, d.span, &d.message, d.help.as_deref()));
out.push('\n');
}
out.push_str(&format!(
"error: {} problem(s) found; nothing was written\n",
diags.len()
));
out
})?;
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
let planned = emitter.plan(&resolved).map_err(|errs| {
let mut out = String::new();
for e in &errs {
out.push_str(&format!("error: {e}\n"));
}
out.push_str("error: nothing was written\n");
out
})?;
// Through `watch::compile` rather than parse/resolve/plan inline, because
// that is the only path that expands `source`. Doing it by hand here meant
// `resolve` never saw the included text -- `flatten` drops `Item::Source`
// -- so a sourced file was silently ignored by `apply` while `watch`
// honoured it. An include that works in one and vanishes in the other is
// worse than one that is unsupported in both.
let compiled = watch::compile(config_path, &emitter).map_err(|e| e.to_string())?;
let planned = compiled.planned;
let changes: Vec<_> = planned.iter().filter(|p| !p.is_noop()).collect();
@@ -193,5 +194,56 @@ fn run_import(args: &[String]) -> Result<String, String> {
"\n{dropped} setting(s) did not translate. Re-run with --report for details.\n"
));
}
if args.iter().any(|a| a == "--assets") {
out.push('\n');
out.push_str(&install_assets(src_path, &name, args)?);
}
Ok(out)
}
/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, and
/// the `.theme` files belonging to waybar, rofi and kitty.
///
/// Separate from the conf translation because it is separate in kind — none of
/// it is translated, only placed — and because it writes outside the
/// cosmic-config tree, which every other path in this tool does not.
fn install_assets(src_path: &str, name: &str, args: &[String]) -> Result<String, String> {
let theme_dir = PathBuf::from(src_path)
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("error: {src_path} has no parent directory\n"))?;
let source_dir = match args.iter().position(|a| a == "--source") {
Some(i) => match args.get(i + 1) {
Some(p) => Some(PathBuf::from(p)),
None => return Err(format!("error: --source needs a path\n\n{USAGE}")),
},
None => find_source_dir(&theme_dir),
};
let installer = assets::Installer::from_env().map_err(|e| format!("error: {e}\n"))?;
let plan = installer
.plan(
&theme_dir,
source_dir.as_deref(),
name,
args.iter().any(|a| a == "--overwrite"),
)
.map_err(|errors| {
errors
.iter()
.map(|e| format!("error: {e}\n"))
.collect::<String>()
})?;
if args.iter().any(|a| a == "--dry-run") {
return Ok(assets::render_plan(&plan));
}
let report = installer
.apply(&plan)
.map_err(|e| format!("error: {e}\n"))?;
Ok(assets::render_report(&plan, &report))
}