From e46514cbab4783a30b02b5a1d15ac9e08117f688 Mon Sep 17 00:00:00 2001 From: dingo Date: Mon, 10 Aug 2026 09:11:08 +0700 Subject: [PATCH] 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. --- config/autostart | 12 ++- config/waybar/bridge-hyde.css | 26 ++++++ config/waybar/palette.css | 33 ++++++++ config/waybar/rules.css | 63 ++++++++++++++ config/waybar/style.css | 96 ++++++--------------- cosmic-conf/src/assets.rs | 152 +++++++++++++++++++++++++++++++++- cosmic-conf/src/main.rs | 112 ++++++++++++++++++------- 7 files changed, 387 insertions(+), 107 deletions(-) create mode 100644 config/waybar/bridge-hyde.css create mode 100644 config/waybar/palette.css create mode 100644 config/waybar/rules.css diff --git a/config/autostart b/config/autostart index f139618..a0a92a5 100644 --- a/config/autostart +++ b/config/autostart @@ -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 diff --git a/config/waybar/bridge-hyde.css b/config/waybar/bridge-hyde.css new file mode 100644 index 0000000..4014fe4 --- /dev/null +++ b/config/waybar/bridge-hyde.css @@ -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); diff --git a/config/waybar/palette.css b/config/waybar/palette.css new file mode 100644 index 0000000..bf3b9de --- /dev/null +++ b/config/waybar/palette.css @@ -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); diff --git a/config/waybar/rules.css b/config/waybar/rules.css new file mode 100644 index 0000000..cf6a8bb --- /dev/null +++ b/config/waybar/rules.css @@ -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; } diff --git a/config/waybar/style.css b/config/waybar/style.css index 07a59eb..3eefba6 100644 --- a/config/waybar/style.css +++ b/config/waybar/style.css @@ -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"); diff --git a/cosmic-conf/src/assets.rs b/cosmic-conf/src/assets.rs index bec0680..266daae 100644 --- a/cosmic-conf/src/assets.rs +++ b/cosmic-conf/src/assets.rs @@ -158,6 +158,56 @@ pub struct Report { pub installed: Vec, } +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 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 { diff --git a/cosmic-conf/src/main.rs b/cosmic-conf/src/main.rs index ae8bba6..597e551 100644 --- a/cosmic-conf/src/main.rs +++ b/cosmic-conf/src/main.rs @@ -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 ] cosmic-conf import-theme [--out ] [--report] + [--assets [--source ] [--overwrite] [--dry-run]] OPTIONS: --diff Show what would change without writing anything --config Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf) --out 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 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//`). 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 { + theme_dir + .ancestors() + .take(6) + .map(|a| a.join("Source")) + .find(|c| c.is_dir()) +} + fn default_config_path() -> Option { 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 { - 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 { "\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 { + 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::() + })?; + + 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)) +}