From 362f3247551d8bf9828d423ac8c4c47e96c6a9c2 Mon Sep 17 00:00:00 2001 From: dingo Date: Mon, 10 Aug 2026 11:48:02 +0700 Subject: [PATCH] import-theme: write rofi's entry point and per-machine overrides The four-layer rofi chain needs config.rasi and local.rasi to exist, and neither has an upstream file to copy: a HyDE theme has no equivalent of either. They were hand-written for this machine, which meant a second machine got a launcher that reported a missing @import instead of opening. config.rasi is `include_str!`d from config/rofi/ rather than kept as a string literal, so it stays a real .rasi file -- highlightable, diffable, editable without a rebuild to see the result -- and there is one copy of it rather than two that can disagree. local.rasi is composed per machine from two things the repo cannot know: the theme's $ICON_THEME, and a wallpaper path. Each half is omitted entirely when there is nothing to say, because an empty `icon-theme:` list is something rofi would honour. $ICON_THEME comes back as a field on Import rather than being re-parsed out of the conf text that the same function just rendered. The wallpaper needed a stable name. local.rasi cannot hardcode a filename without going stale at the next theme import, so plan_wallpapers now also maintains ~/.local/share/wallpapers/hyprcosmic/current as a symlink to one of the copies it made. HyDE has this problem too and solves it the same way, with ~/.cache/hyde/wall.thmb. The launcher sidebar and the autostart's `awww img` line both name the link, so they cannot drift apart. Which wallpaper it points at is the first in *sorted* order. read_dir returns whatever the filesystem feels like, and an arbitrary choice is fine where an unrepeatable one is not: re-running the import would otherwise change the wallpaper at random. The link is repointed even when every wallpaper was skipped as already installed -- the copies are theme-specific and unchanged, but the link is global and has to follow the theme just imported. Repointing has to handle a *dangling* link, which is exactly what a previous import leaves behind once its theme directory is gone: symlink(2) fails with EEXIST rather than replacing, and Path::exists follows the link, so it answers false for the one case that needs removing. symlink_metadata asks about the link itself. Values reaching a generated config come from a theme directory that may have been downloaded from anywhere, so quote_rasi_string strips quotes, backslashes and control characters -- .rasi has no escape syntax worth relying on. Four existing tests counted actions and broke once every plan carried two more. Fixed with a theme_assets() filter rather than by bumping the numbers, so what they are actually asserting stays visible and the next generated file does not break them again. 125 unit, 5 bin and 4 integration tests pass; clippy --all-targets and `cargo fmt --check` are clean. A real import of Tokyo Night installed all 13 paths, and `cosmic-conf apply --diff` reports no changes afterwards. --- cosmic-conf/src/assets.rs | 476 ++++++++++++++++++++++++++-- cosmic-conf/src/import.rs | 14 +- cosmic-conf/src/main.rs | 28 +- cosmic-conf/tests/archive_escape.rs | 8 +- 4 files changed, 487 insertions(+), 39 deletions(-) diff --git a/cosmic-conf/src/assets.rs b/cosmic-conf/src/assets.rs index 6a456b4..613f522 100644 --- a/cosmic-conf/src/assets.rs +++ b/cosmic-conf/src/assets.rs @@ -148,6 +148,23 @@ pub enum Action { dest: PathBuf, contents: String, }, + /// A file this crate composes rather than copies. No `src`, because there + /// is no upstream file: rofi's entry point and its per-machine overrides + /// exist only because HyprCosmic needs them, and a HyDE theme has no + /// equivalent to copy from. + WriteGenerated { + kind: AssetKind, + dest: PathBuf, + contents: String, + }, + /// A stable name for the wallpaper in use, as a symlink beside the copies. + /// + /// HyDE has the same problem and solves it the same way: everything that + /// wants to show the current wallpaper -- the launcher's sidebar, the + /// autostart's `awww img` line -- needs one path that does not change when + /// the theme does. HyDE points them at `~/.cache/hyde/wall.thmb`; this is + /// that, under a name we own. + LinkWallpaper { link: PathBuf, target: PathBuf }, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -191,8 +208,10 @@ impl Draft { impl Action { pub fn kind(&self) -> AssetKind { match self { - Action::ExtractArchive { kind, .. } | Action::CopyVerbatim { kind, .. } => *kind, - Action::CopyWallpaper { .. } => AssetKind::Wallpaper, + Action::ExtractArchive { kind, .. } + | Action::CopyVerbatim { kind, .. } + | Action::WriteGenerated { kind, .. } => *kind, + Action::CopyWallpaper { .. } | Action::LinkWallpaper { .. } => AssetKind::Wallpaper, } } @@ -200,7 +219,9 @@ impl Action { match self { Action::ExtractArchive { dest, .. } | Action::CopyWallpaper { dest, .. } - | Action::CopyVerbatim { dest, .. } => dest, + | Action::CopyVerbatim { dest, .. } + | Action::WriteGenerated { dest, .. } => dest, + Action::LinkWallpaper { link, .. } => link, } } } @@ -308,6 +329,7 @@ impl Installer { theme_dir: &Path, source_dir: Option<&Path>, theme_name: &str, + icon_theme: Option<&str>, overwrite: bool, ) -> Result> { let mut draft = Draft::default(); @@ -335,7 +357,7 @@ impl Installer { } } - self.plan_wallpapers(theme_dir, theme_name, overwrite, &mut draft); + let wallpaper = self.plan_wallpapers(theme_dir, theme_name, overwrite, &mut draft); for (kind, filename) in [ (AssetKind::Waybar, "waybar.theme"), @@ -345,9 +367,61 @@ impl Installer { self.plan_verbatim(theme_dir, kind, filename, overwrite, &mut draft); } + self.plan_rofi(icon_theme, wallpaper.as_deref(), overwrite, &mut draft); + draft.finish() } + /// Where the launcher and the autostart both look for the wallpaper. + /// See `Action::LinkWallpaper`. + fn current_wallpaper_link(&self) -> PathBuf { + self.data_home + .join("wallpapers") + .join("hyprcosmic") + .join("current") + } + + /// rofi's entry point and its per-machine overrides. + /// + /// Unlike everything else here these are composed, not copied — a HyDE + /// theme has no rofi config, only a palette, because HyDE supplies the + /// layout from its own launcher script and we have no launcher script. See + /// `config/rofi/config.rasi` for what the four-file import chain is doing. + /// + /// Both files are subject to the same already-installed rule as the rest: + /// a re-import will not silently replace a `local.rasi` you have edited. + /// That does mean switching themes needs `--overwrite` to take effect, but + /// so does `theme.rasi` beside it, and one rule that always holds beats two + /// that nearly do. + fn plan_rofi( + &self, + icon_theme: Option<&str>, + wallpaper_link: Option<&Path>, + overwrite: bool, + draft: &mut Draft, + ) { + let rofi_dir = self.home.join(".config").join("rofi"); + for (name, contents) in [ + ("config.rasi", CONFIG_RASI.to_string()), + ("local.rasi", render_local_rasi(icon_theme, wallpaper_link)), + ] { + let dest = rofi_dir.join(name); + if !overwrite && dest.exists() { + draft.skipped.push(Note { + kind: AssetKind::Rofi, + path: dest, + reason: SkipReason::AlreadyInstalled, + }); + continue; + } + draft.actions.push(Action::WriteGenerated { + kind: AssetKind::Rofi, + dest, + contents, + }); + } + } + fn plan_archive( &self, kind: AssetKind, @@ -373,16 +447,19 @@ impl Installer { })) } + /// Returns the path of the stable `current` symlink when the theme has a + /// wallpaper to point it at, so the caller can wire the launcher up to the + /// same image. fn plan_wallpapers( &self, theme_dir: &Path, theme_name: &str, overwrite: bool, draft: &mut Draft, - ) { + ) -> Option { let wallpapers_dir = theme_dir.join("wallpapers"); if !wallpapers_dir.is_dir() { - return; + return None; } let dest_dir = self .data_home @@ -394,9 +471,14 @@ impl Installer { Ok(e) => e, Err(e) => { draft.errors.push(e.into()); - return; + return None; } }; + // Sorted, because one of these becomes the `current` symlink and + // `read_dir` order is whatever the filesystem feels like. An arbitrary + // choice is fine; an unrepeatable one is not -- re-running the import + // would silently change the wallpaper. + let mut sources = Vec::new(); for entry in entries { let entry = match entry { Ok(e) => e, @@ -405,11 +487,17 @@ impl Installer { continue; } }; - let src = entry.path(); - if !src.is_file() { - continue; + if entry.path().is_file() { + sources.push(entry.file_name()); } - let dest = dest_dir.join(entry.file_name()); + } + sources.sort(); + + let first = sources.first().map(|name| dest_dir.join(name)); + + for name in &sources { + let src = wallpapers_dir.join(name); + let dest = dest_dir.join(name); if !overwrite && dest.exists() { draft.skipped.push(Note { kind: AssetKind::Wallpaper, @@ -420,6 +508,17 @@ impl Installer { } draft.actions.push(Action::CopyWallpaper { src, dest }); } + + // The link is repointed even when every wallpaper was skipped as + // already installed: the copies are theme-specific and unchanged, but + // the link is global and has to follow the theme just imported. + let target = first?; + let link = self.current_wallpaper_link(); + draft.actions.push(Action::LinkWallpaper { + link: link.clone(), + target, + }); + Some(link) } fn plan_verbatim( @@ -484,19 +583,117 @@ impl Installer { fs::copy(src, dest)?; installed.push(dest.clone()); } - Action::CopyVerbatim { dest, contents, .. } => { + Action::CopyVerbatim { dest, contents, .. } + | Action::WriteGenerated { dest, contents, .. } => { if let Some(parent) = dest.parent() { fs::create_dir_all(parent)?; } fs::write(dest, contents)?; installed.push(dest.clone()); } + Action::LinkWallpaper { link, target } => { + if let Some(parent) = link.parent() { + fs::create_dir_all(parent)?; + } + // `symlink` fails with EEXIST rather than replacing, and + // `link.exists()` follows the link, so it answers false for + // a dangling one -- which is exactly the case a previous + // import leaves behind after its theme directory is gone. + // `symlink_metadata` asks about the link itself. + if fs::symlink_metadata(link).is_ok() { + fs::remove_file(link)?; + } + std::os::unix::fs::symlink(target, link)?; + installed.push(link.clone()); + } } } Ok(Report { installed }) } } +/// rofi's entry point, embedded from the repo rather than kept as a string +/// literal here so that it stays a real `.rasi` file: syntax-highlightable, +/// diffable, and editable without recompiling to see the result. +const CONFIG_RASI: &str = include_str!("../../config/rofi/config.rasi"); + +/// Compose `~/.config/rofi/local.rasi` — the last of the four imports in +/// `config.rasi`, holding the two things that depend on this machine rather +/// than on the theme file or on `/usr/share`. +/// +/// Both parts are optional and each is simply left out when there is nothing +/// to say. An absent `configuration` block leaves rofi on its own icon theme; +/// an absent `dummywall` rule leaves the sidebar filled with `@main-bg` from +/// `rules.rasi`. Emitting a block with an empty value in either case would be +/// worse than emitting nothing, because rofi would honour it. +fn render_local_rasi(icon_theme: Option<&str>, wallpaper_link: Option<&Path>) -> String { + 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 + * 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. + */ +"#, + ); + + match icon_theme.map(quote_rasi_string) { + Some(theme) => out.push_str(&format!( + r#" +/* A list, so the first one actually installed wins. The theme names the first; + * Adwaita is the freedesktop baseline and is always present. */ +configuration {{ + icon-theme: {theme}, "Adwaita"; +}} +"# + )), + None => out.push_str("\n/* The theme names no icon theme, so rofi keeps its own. */\n"), + } + + match wallpaper_link.map(|p| quote_rasi_string(&p.to_string_lossy())) { + Some(link) => out.push_str(&format!( + r#" +/* The sidebar image. rofi's second url() argument is the scaling mode: "height" + * fills the panel vertically and crops the sides, which is how HyDE's style_1 + * uses its wallpaper thumbnail. + * + * This is a symlink, not one of the copies beside it, so that the launcher and + * the autostart's `awww img` line can name the same path and stay in step + * through a theme change. Repoint the link, not this file. */ +dummywall {{ + background-image: url({link}, height); +}} +"# + )), + None => out.push_str( + "\n/* The theme ships no wallpaper, so the sidebar stays a flat panel in\n \ + * the theme's background colour. */\n", + ), + } + + out +} + +/// Quote a value for `.rasi`, which has no escape syntax worth relying on. +/// +/// The icon theme name arrives from a downloaded theme file and so is +/// untrusted; a `"` in it would close the string early and turn the rest of +/// the generated file into whatever the theme author wanted. Dropping the +/// characters that could do that is enough here — every value this is used on +/// is a name or a path, where a quote or a newline is malformed input rather +/// than something to preserve. +fn quote_rasi_string(value: &str) -> String { + let cleaned: String = value + .chars() + .filter(|c| *c != '"' && *c != '\\' && !c.is_control()) + .collect(); + format!("\"{cleaned}\"") +} + /// Every HyDE `.theme` file — not just `hypr.theme` — opens with a /// destination line for HyDE's own installer. Verified against /// `HyDE-Project/hyde-themes`, branch `Catppuccin-Mocha`: @@ -768,6 +965,31 @@ mod tests { fs::write(path, contents).unwrap(); } + /// Every plan carries rofi's `config.rasi` and `local.rasi`, whatever the + /// theme does or does not ship. Tests about one particular asset look past + /// that scaffolding rather than counting it, so that adding another + /// generated file later does not break assertions about wallpapers. + fn theme_assets(plan: &Plan) -> Vec<&Action> { + plan.actions + .iter() + .filter(|a| !matches!(a, Action::WriteGenerated { .. })) + .collect() + } + + fn generated(plan: &Plan, filename: &str) -> String { + plan.actions + .iter() + .find_map(|a| match a { + Action::WriteGenerated { dest, contents, .. } + if dest.file_name().unwrap() == filename => + { + Some(contents.clone()) + } + _ => None, + }) + .unwrap_or_else(|| panic!("no generated {filename} in plan")) + } + #[test] fn path_traversal_tarball_is_rejected() { let tmp = TempDir::new().unwrap(); @@ -783,7 +1005,7 @@ mod tests { let home = tmp.path().join("home"); let installer = Installer::with_paths(home.join(".local/share"), &home); let err = installer - .plan(&theme_dir, Some(&source_dir), "Evil", false) + .plan(&theme_dir, Some(&source_dir), "Evil", None, false) .expect_err("a path-traversal entry must be rejected, not silently extracted"); assert!( err.iter() @@ -808,7 +1030,7 @@ mod tests { let installer = Installer::with_paths(home.join(".local/share"), &home); let err = installer - .plan(&theme_dir, Some(&source_dir), "Evil", false) + .plan(&theme_dir, Some(&source_dir), "Evil", None, false) .unwrap_err(); assert!( err.iter() @@ -838,7 +1060,7 @@ mod tests { let home = tmp.path().join("home"); let installer = Installer::with_paths(home.join(".local/share"), &home); let plan = installer - .plan(&theme_dir, Some(&source_dir), "Mocha", false) + .plan(&theme_dir, Some(&source_dir), "Mocha", None, false) .expect("a well-formed theme must plan cleanly"); assert!(!plan.actions.is_empty()); @@ -857,7 +1079,9 @@ mod tests { let home = tmp.path().join("home"); let data_home = home.join(".local/share"); let installer = Installer::with_paths(&data_home, &home); - let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); let report = installer.apply(&plan).unwrap(); let dest = data_home.join("wallpapers/hyprcosmic/Mocha/bg.png"); @@ -879,7 +1103,9 @@ mod tests { let home = tmp.path().join("home"); let installer = Installer::with_paths(home.join(".local/share"), &home); - let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); let report = installer.apply(&plan).unwrap(); let dest = home.join(".config/rofi/theme.rasi"); @@ -906,7 +1132,9 @@ mod tests { let home = tmp.path().join("home"); let installer = Installer::with_paths(home.join(".local/share"), &home); - let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); installer.apply(&plan).unwrap(); assert_eq!( @@ -940,7 +1168,7 @@ mod tests { let home = tmp.path().join("home"); let installer = Installer::with_paths(home.join(".local/share"), &home); let plan = installer - .plan(&theme_dir, Some(&source_dir), "Mocha", false) + .plan(&theme_dir, Some(&source_dir), "Mocha", None, false) .unwrap(); installer.apply(&plan).unwrap(); @@ -973,11 +1201,11 @@ mod tests { let installer = Installer::with_paths(home.join(".local/share"), &home); let plan = installer - .plan(&theme_dir, Some(&source_dir), "Mocha", false) + .plan(&theme_dir, Some(&source_dir), "Mocha", None, false) .unwrap(); assert!( - plan.actions.is_empty(), + theme_assets(&plan).is_empty(), "already-installed theme must not be re-planned" ); assert_eq!(plan.skipped.len(), 1); @@ -1010,9 +1238,9 @@ mod tests { let installer = Installer::with_paths(home.join(".local/share"), &home); let plan = installer - .plan(&theme_dir, Some(&source_dir), "Mocha", true) + .plan(&theme_dir, Some(&source_dir), "Mocha", None, true) .unwrap(); - assert_eq!(plan.actions.len(), 1); + assert_eq!(theme_assets(&plan).len(), 1); installer.apply(&plan).unwrap(); assert_eq!( @@ -1029,9 +1257,11 @@ mod tests { let home = tmp.path().join("home"); let installer = Installer::with_paths(home.join(".local/share"), &home); - let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); - assert!(plan.actions.is_empty()); + assert!(theme_assets(&plan).is_empty()); assert_eq!(plan.skipped.len(), 1); assert_eq!(plan.skipped[0].reason, SkipReason::NoDestinationHeader); } @@ -1046,11 +1276,199 @@ mod tests { let home = tmp.path().join("home"); let installer = Installer::with_paths(home.join(".local/share"), &home); - let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); - assert_eq!(plan.actions.len(), 1); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); + // The wallpaper copy and the `current` symlink that points at it. + assert_eq!(theme_assets(&plan).len(), 2); assert!(plan.skipped.is_empty()); } + #[test] + fn rofi_entry_point_is_written_even_for_a_theme_that_ships_nothing() { + // config.rasi is what makes rofi read any of this: rofi loads no other + // filename on its own. A theme with no rofi.theme still needs it, or + // the launcher falls back to its stock grey. + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); + installer.apply(&plan).unwrap(); + + let written = fs::read_to_string(home.join(".config/rofi/config.rasi")).unwrap(); + assert!(written.contains("@import \"theme.rasi\""), "{written}"); + assert!(written.contains("@import \"local.rasi\""), "{written}"); + } + + #[test] + fn local_rasi_carries_the_icon_theme_and_the_wallpaper_link() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + write(&theme_dir.join("wallpapers/bg.png"), "x"); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer + .plan( + &theme_dir, + None, + "Mocha", + Some("Tela-circle-dracula"), + false, + ) + .unwrap(); + + let local = generated(&plan, "local.rasi"); + assert!( + local.contains(r#""Tela-circle-dracula", "Adwaita""#), + "{local}" + ); + // The stable link, not the copy: see `Action::LinkWallpaper`. + assert!(local.contains("wallpapers/hyprcosmic/current"), "{local}"); + assert!(!local.contains("bg.png"), "{local}"); + } + + #[test] + fn local_rasi_omits_what_the_theme_does_not_supply() { + // An empty block would be worse than no block: rofi would honour an + // empty icon-theme list, and an unset background-image is what leaves + // the sidebar on the theme's own colour. + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); + + let local = generated(&plan, "local.rasi"); + assert!(!local.contains("icon-theme"), "{local}"); + assert!(!local.contains("background-image"), "{local}"); + } + + #[test] + fn a_quote_in_an_icon_theme_name_cannot_break_out_of_the_string() { + // The name comes from a theme file that may have been downloaded from + // anywhere, and lands in a config rofi will execute nothing from but + // will happily be reconfigured by. + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer + .plan( + &theme_dir, + None, + "Mocha", + Some("Tela\"; terminal: \"evil"), + false, + ) + .unwrap(); + + let local = generated(&plan, "local.rasi"); + // The payload survives as text -- it is a name, and mangling it beyond + // recognition would be its own bug -- but only as text: it stays inside + // one quoted string, so `terminal` is never a property rofi sets. + assert!( + local.contains(r#""Tela; terminal: evil", "Adwaita";"#), + "{local}" + ); + assert!( + !local + .lines() + .any(|l| l.trim_start().starts_with("terminal:")), + "{local}" + ); + } + + #[test] + fn the_current_wallpaper_link_is_the_first_in_sorted_order() { + // Arbitrary is fine; unrepeatable is not. `read_dir` order would make + // re-importing the same theme change the wallpaper at random. + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + for name in ["zebra.png", "apple.png", "middle.png"] { + write(&theme_dir.join("wallpapers").join(name), "x"); + } + + let home = tmp.path().join("home"); + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); + installer.apply(&plan).unwrap(); + + let link = home.join(".local/share/wallpapers/hyprcosmic/current"); + assert_eq!( + fs::read_link(&link).unwrap().file_name().unwrap(), + "apple.png" + ); + } + + #[test] + fn the_wallpaper_link_is_repointed_rather_than_failing_on_a_stale_one() { + // The second import is the interesting one: `symlink` refuses to + // replace, and a link left dangling by a removed theme directory reads + // as absent to `Path::exists`. + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let link = home.join(".local/share/wallpapers/hyprcosmic/current"); + fs::create_dir_all(link.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(tmp.path().join("gone/old.png"), &link).unwrap(); + assert!( + !link.exists(), + "the stale link must be dangling for this test" + ); + + let theme_dir = tmp.path().join("theme"); + write(&theme_dir.join("wallpapers/new.png"), "x"); + + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); + installer.apply(&plan).unwrap(); + + assert_eq!( + fs::read_link(&link).unwrap().file_name().unwrap(), + "new.png" + ); + } + + #[test] + fn an_edited_local_rasi_is_not_replaced_without_overwrite() { + let tmp = TempDir::new().unwrap(); + let theme_dir = tmp.path().join("theme"); + fs::create_dir_all(&theme_dir).unwrap(); + + let home = tmp.path().join("home"); + write(&home.join(".config/rofi/local.rasi"), "/* mine */\n"); + + let installer = Installer::with_paths(home.join(".local/share"), &home); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); + installer.apply(&plan).unwrap(); + + assert_eq!( + fs::read_to_string(home.join(".config/rofi/local.rasi")).unwrap(), + "/* mine */\n" + ); + assert!(plan + .skipped + .iter() + .any(|n| n.path.ends_with("local.rasi") && n.reason == SkipReason::AlreadyInstalled)); + } + #[test] fn report_lists_installed_and_skipped() { let tmp = TempDir::new().unwrap(); @@ -1060,7 +1478,9 @@ mod tests { let home = tmp.path().join("home"); let installer = Installer::with_paths(home.join(".local/share"), &home); - let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); + let plan = installer + .plan(&theme_dir, None, "Mocha", None, false) + .unwrap(); let report = installer.apply(&plan).unwrap(); let text = render_report(&plan, &report); diff --git a/cosmic-conf/src/import.rs b/cosmic-conf/src/import.rs index 89426c9..9214d11 100644 --- a/cosmic-conf/src/import.rs +++ b/cosmic-conf/src/import.rs @@ -49,6 +49,13 @@ pub struct Import { pub conf: String, /// Everything that did not translate cleanly. pub notes: Vec, + /// The theme's `$ICON_THEME`, if it names one. + /// + /// Also present in `conf` as `theme.icon_theme`, but repeated here as a + /// field because `assets.rs` needs it to generate rofi's `local.rasi` and + /// re-parsing the text this function just rendered to get it back would be + /// absurd. + pub icon_theme: Option, } impl Import { @@ -151,10 +158,14 @@ pub fn import_hypr_theme(src: &str, theme_name: &str) -> Result = Vec::new(); let mut theme: Vec<(String, String)> = Vec::new(); let mut notes = Vec::new(); + let mut icon_theme = None; for (name, value, span) in &vars { match name.as_str() { - "ICON_THEME" => theme.push(("icon_theme".into(), value.clone())), + "ICON_THEME" => { + theme.push(("icon_theme".into(), value.clone())); + icon_theme = Some(value.clone()); + } "COLOR_SCHEME" => { let mode = if value.contains("light") { "light" @@ -249,6 +260,7 @@ pub fn import_hypr_theme(src: &str, theme_name: &str) -> Result Result { if args.iter().any(|a| a == "--assets") { out.push('\n'); - out.push_str(&install_assets(src_path, &name, args)?); + out.push_str(&install_assets( + src_path, + &name, + imported.icon_theme.as_deref(), + args, + )?); } Ok(out) @@ -324,13 +329,23 @@ fn unsourced_hint(written: &Path) -> Option { )) } -/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, and -/// the `.theme` files belonging to waybar, rofi and kitty. +/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, the +/// `.theme` files belonging to waybar, rofi and kitty, and the small rofi +/// files HyprCosmic has to compose itself. /// -/// Separate from the conf translation because it is separate in kind — none of -/// it is translated, only placed — and because it writes outside the +/// Separate from the conf translation because it is separate in kind — almost +/// none of it is translated, only placed — and because it writes outside the /// cosmic-config tree, which every other path in this tool does not. -fn install_assets(src_path: &str, name: &str, args: &[String]) -> Result { +/// +/// `icon_theme` comes back out of the conf translation rather than being read +/// from the theme directory again, because that is where the `$ICON_THEME` +/// variable was already resolved. +fn install_assets( + src_path: &str, + name: &str, + icon_theme: Option<&str>, + args: &[String], +) -> Result { let theme_dir = PathBuf::from(src_path) .parent() .map(Path::to_path_buf) @@ -350,6 +365,7 @@ fn install_assets(src_path: &str, name: &str, args: &[String]) -> Result