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.
This commit is contained in:
2026-08-10 11:48:02 +07:00
parent 1d3252aabd
commit 362f324755
4 changed files with 487 additions and 39 deletions
+448 -28
View File
@@ -148,6 +148,23 @@ pub enum Action {
dest: PathBuf, dest: PathBuf,
contents: String, 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)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -191,8 +208,10 @@ impl Draft {
impl Action { impl Action {
pub fn kind(&self) -> AssetKind { pub fn kind(&self) -> AssetKind {
match self { match self {
Action::ExtractArchive { kind, .. } | Action::CopyVerbatim { kind, .. } => *kind, Action::ExtractArchive { kind, .. }
Action::CopyWallpaper { .. } => AssetKind::Wallpaper, | Action::CopyVerbatim { kind, .. }
| Action::WriteGenerated { kind, .. } => *kind,
Action::CopyWallpaper { .. } | Action::LinkWallpaper { .. } => AssetKind::Wallpaper,
} }
} }
@@ -200,7 +219,9 @@ impl Action {
match self { match self {
Action::ExtractArchive { dest, .. } Action::ExtractArchive { dest, .. }
| Action::CopyWallpaper { 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, theme_dir: &Path,
source_dir: Option<&Path>, source_dir: Option<&Path>,
theme_name: &str, theme_name: &str,
icon_theme: Option<&str>,
overwrite: bool, overwrite: bool,
) -> Result<Plan, Vec<AssetError>> { ) -> Result<Plan, Vec<AssetError>> {
let mut draft = Draft::default(); 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 [ for (kind, filename) in [
(AssetKind::Waybar, "waybar.theme"), (AssetKind::Waybar, "waybar.theme"),
@@ -345,9 +367,61 @@ impl Installer {
self.plan_verbatim(theme_dir, kind, filename, overwrite, &mut draft); self.plan_verbatim(theme_dir, kind, filename, overwrite, &mut draft);
} }
self.plan_rofi(icon_theme, wallpaper.as_deref(), overwrite, &mut draft);
draft.finish() 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( fn plan_archive(
&self, &self,
kind: AssetKind, 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( fn plan_wallpapers(
&self, &self,
theme_dir: &Path, theme_dir: &Path,
theme_name: &str, theme_name: &str,
overwrite: bool, overwrite: bool,
draft: &mut Draft, draft: &mut Draft,
) { ) -> Option<PathBuf> {
let wallpapers_dir = theme_dir.join("wallpapers"); let wallpapers_dir = theme_dir.join("wallpapers");
if !wallpapers_dir.is_dir() { if !wallpapers_dir.is_dir() {
return; return None;
} }
let dest_dir = self let dest_dir = self
.data_home .data_home
@@ -394,9 +471,14 @@ impl Installer {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
draft.errors.push(e.into()); 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 { for entry in entries {
let entry = match entry { let entry = match entry {
Ok(e) => e, Ok(e) => e,
@@ -405,11 +487,17 @@ impl Installer {
continue; continue;
} }
}; };
let src = entry.path(); if entry.path().is_file() {
if !src.is_file() { sources.push(entry.file_name());
continue;
} }
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() { if !overwrite && dest.exists() {
draft.skipped.push(Note { draft.skipped.push(Note {
kind: AssetKind::Wallpaper, kind: AssetKind::Wallpaper,
@@ -420,6 +508,17 @@ impl Installer {
} }
draft.actions.push(Action::CopyWallpaper { src, dest }); 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( fn plan_verbatim(
@@ -484,19 +583,117 @@ impl Installer {
fs::copy(src, dest)?; fs::copy(src, dest)?;
installed.push(dest.clone()); installed.push(dest.clone());
} }
Action::CopyVerbatim { dest, contents, .. } => { Action::CopyVerbatim { dest, contents, .. }
| Action::WriteGenerated { dest, contents, .. } => {
if let Some(parent) = dest.parent() { if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
fs::write(dest, contents)?; fs::write(dest, contents)?;
installed.push(dest.clone()); 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 }) 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 /// Every HyDE `.theme` file — not just `hypr.theme` — opens with a
/// destination line for HyDE's own installer. Verified against /// destination line for HyDE's own installer. Verified against
/// `HyDE-Project/hyde-themes`, branch `Catppuccin-Mocha`: /// `HyDE-Project/hyde-themes`, branch `Catppuccin-Mocha`:
@@ -768,6 +965,31 @@ mod tests {
fs::write(path, contents).unwrap(); 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] #[test]
fn path_traversal_tarball_is_rejected() { fn path_traversal_tarball_is_rejected() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -783,7 +1005,7 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let installer = Installer::with_paths(home.join(".local/share"), &home); let installer = Installer::with_paths(home.join(".local/share"), &home);
let err = installer 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"); .expect_err("a path-traversal entry must be rejected, not silently extracted");
assert!( assert!(
err.iter() err.iter()
@@ -808,7 +1030,7 @@ mod tests {
let installer = Installer::with_paths(home.join(".local/share"), &home); let installer = Installer::with_paths(home.join(".local/share"), &home);
let err = installer let err = installer
.plan(&theme_dir, Some(&source_dir), "Evil", false) .plan(&theme_dir, Some(&source_dir), "Evil", None, false)
.unwrap_err(); .unwrap_err();
assert!( assert!(
err.iter() err.iter()
@@ -838,7 +1060,7 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let installer = Installer::with_paths(home.join(".local/share"), &home); let installer = Installer::with_paths(home.join(".local/share"), &home);
let plan = installer 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"); .expect("a well-formed theme must plan cleanly");
assert!(!plan.actions.is_empty()); assert!(!plan.actions.is_empty());
@@ -857,7 +1079,9 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let data_home = home.join(".local/share"); let data_home = home.join(".local/share");
let installer = Installer::with_paths(&data_home, &home); 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 report = installer.apply(&plan).unwrap();
let dest = data_home.join("wallpapers/hyprcosmic/Mocha/bg.png"); let dest = data_home.join("wallpapers/hyprcosmic/Mocha/bg.png");
@@ -879,7 +1103,9 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let installer = Installer::with_paths(home.join(".local/share"), &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 report = installer.apply(&plan).unwrap();
let dest = home.join(".config/rofi/theme.rasi"); let dest = home.join(".config/rofi/theme.rasi");
@@ -906,7 +1132,9 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let installer = Installer::with_paths(home.join(".local/share"), &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(); installer.apply(&plan).unwrap();
assert_eq!( assert_eq!(
@@ -940,7 +1168,7 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let installer = Installer::with_paths(home.join(".local/share"), &home); let installer = Installer::with_paths(home.join(".local/share"), &home);
let plan = installer let plan = installer
.plan(&theme_dir, Some(&source_dir), "Mocha", false) .plan(&theme_dir, Some(&source_dir), "Mocha", None, false)
.unwrap(); .unwrap();
installer.apply(&plan).unwrap(); installer.apply(&plan).unwrap();
@@ -973,11 +1201,11 @@ mod tests {
let installer = Installer::with_paths(home.join(".local/share"), &home); let installer = Installer::with_paths(home.join(".local/share"), &home);
let plan = installer let plan = installer
.plan(&theme_dir, Some(&source_dir), "Mocha", false) .plan(&theme_dir, Some(&source_dir), "Mocha", None, false)
.unwrap(); .unwrap();
assert!( assert!(
plan.actions.is_empty(), theme_assets(&plan).is_empty(),
"already-installed theme must not be re-planned" "already-installed theme must not be re-planned"
); );
assert_eq!(plan.skipped.len(), 1); assert_eq!(plan.skipped.len(), 1);
@@ -1010,9 +1238,9 @@ mod tests {
let installer = Installer::with_paths(home.join(".local/share"), &home); let installer = Installer::with_paths(home.join(".local/share"), &home);
let plan = installer let plan = installer
.plan(&theme_dir, Some(&source_dir), "Mocha", true) .plan(&theme_dir, Some(&source_dir), "Mocha", None, true)
.unwrap(); .unwrap();
assert_eq!(plan.actions.len(), 1); assert_eq!(theme_assets(&plan).len(), 1);
installer.apply(&plan).unwrap(); installer.apply(&plan).unwrap();
assert_eq!( assert_eq!(
@@ -1029,9 +1257,11 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let installer = Installer::with_paths(home.join(".local/share"), &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.len(), 1);
assert_eq!(plan.skipped[0].reason, SkipReason::NoDestinationHeader); assert_eq!(plan.skipped[0].reason, SkipReason::NoDestinationHeader);
} }
@@ -1046,11 +1276,199 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let installer = Installer::with_paths(home.join(".local/share"), &home); let installer = Installer::with_paths(home.join(".local/share"), &home);
let plan = installer.plan(&theme_dir, None, "Mocha", false).unwrap(); let plan = installer
assert_eq!(plan.actions.len(), 1); .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()); 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] #[test]
fn report_lists_installed_and_skipped() { fn report_lists_installed_and_skipped() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -1060,7 +1478,9 @@ mod tests {
let home = tmp.path().join("home"); let home = tmp.path().join("home");
let installer = Installer::with_paths(home.join(".local/share"), &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 report = installer.apply(&plan).unwrap();
let text = render_report(&plan, &report); let text = render_report(&plan, &report);
+13 -1
View File
@@ -49,6 +49,13 @@ pub struct Import {
pub conf: String, pub conf: String,
/// Everything that did not translate cleanly. /// Everything that did not translate cleanly.
pub notes: Vec<Note>, pub notes: Vec<Note>,
/// The theme's `$ICON_THEME`, if it names one.
///
/// Also present in `conf` as `theme.icon_theme`, but repeated here as a
/// field because `assets.rs` needs it to generate rofi's `local.rasi` and
/// re-parsing the text this function just rendered to get it back would be
/// absurd.
pub icon_theme: Option<String>,
} }
impl Import { impl Import {
@@ -151,10 +158,14 @@ pub fn import_hypr_theme(src: &str, theme_name: &str) -> Result<Import, ParseErr
let mut decoration: Vec<(String, String)> = Vec::new(); let mut decoration: Vec<(String, String)> = Vec::new();
let mut theme: Vec<(String, String)> = Vec::new(); let mut theme: Vec<(String, String)> = Vec::new();
let mut notes = Vec::new(); let mut notes = Vec::new();
let mut icon_theme = None;
for (name, value, span) in &vars { for (name, value, span) in &vars {
match name.as_str() { 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" => { "COLOR_SCHEME" => {
let mode = if value.contains("light") { let mode = if value.contains("light") {
"light" "light"
@@ -249,6 +260,7 @@ pub fn import_hypr_theme(src: &str, theme_name: &str) -> Result<Import, ParseErr
Ok(Import { Ok(Import {
conf: render_conf(theme_name, &general, &decoration, &theme, &notes), conf: render_conf(theme_name, &general, &decoration, &theme, &notes),
notes, notes,
icon_theme,
}) })
} }
+22 -6
View File
@@ -280,7 +280,12 @@ fn run_import(args: &[String]) -> Result<String, String> {
if args.iter().any(|a| a == "--assets") { if args.iter().any(|a| a == "--assets") {
out.push('\n'); 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) Ok(out)
@@ -324,13 +329,23 @@ fn unsourced_hint(written: &Path) -> Option<String> {
)) ))
} }
/// The half of a theme that is not config: wallpapers, GTK/icon tarballs, and /// The half of a theme that is not config: wallpapers, GTK/icon tarballs, the
/// the `.theme` files belonging to waybar, rofi and kitty. /// `.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 /// Separate from the conf translation because it is separate in kind — almost
/// it is translated, only placed — and because it writes outside the /// 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. /// 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> { ///
/// `icon_theme` comes back out of the conf translation rather than being read
/// from the theme directory again, because that is where the `$ICON_THEME`
/// variable was already resolved.
fn install_assets(
src_path: &str,
name: &str,
icon_theme: Option<&str>,
args: &[String],
) -> Result<String, String> {
let theme_dir = PathBuf::from(src_path) let theme_dir = PathBuf::from(src_path)
.parent() .parent()
.map(Path::to_path_buf) .map(Path::to_path_buf)
@@ -350,6 +365,7 @@ fn install_assets(src_path: &str, name: &str, args: &[String]) -> Result<String,
&theme_dir, &theme_dir,
source_dir.as_deref(), source_dir.as_deref(),
name, name,
icon_theme,
args.iter().any(|a| a == "--overwrite"), args.iter().any(|a| a == "--overwrite"),
) )
.map_err(|errors| { .map_err(|errors| {
+4 -4
View File
@@ -105,7 +105,7 @@ fn parent_dir_traversal_never_writes_outside_destination() {
let data = home.join(".local/share"); let data = home.join(".local/share");
let installer = Installer::with_paths(&data, &home); let installer = Installer::with_paths(&data, &home);
let result = installer.plan(&theme_dir, Some(&source_dir), "Evil", true); let result = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true);
// Whether it is rejected at plan time or apply time, the invariant is the // Whether it is rejected at plan time or apply time, the invariant is the
// same: nothing lands outside the destination. // same: nothing lands outside the destination.
@@ -128,7 +128,7 @@ fn absolute_path_entry_never_writes_outside_destination() {
let data = home.join(".local/share"); let data = home.join(".local/share");
let installer = Installer::with_paths(&data, &home); let installer = Installer::with_paths(&data, &home);
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", true) { if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true) {
let _ = installer.apply(&plan); let _ = installer.apply(&plan);
} }
assert_nothing_outside(Path::new("/tmp/cosmic_conf_abs_canary")); assert_nothing_outside(Path::new("/tmp/cosmic_conf_abs_canary"));
@@ -152,7 +152,7 @@ fn symlink_indirection_never_writes_outside_destination() {
let data = home.join(".local/share"); let data = home.join(".local/share");
let installer = Installer::with_paths(&data, &home); let installer = Installer::with_paths(&data, &home);
if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", true) { if let Ok(plan) = installer.plan(&theme_dir, Some(&source_dir), "Evil", None, true) {
let _ = installer.apply(&plan); let _ = installer.apply(&plan);
} }
assert_nothing_outside(Path::new("/tmp/cosmic_conf_symlink_canary")); assert_nothing_outside(Path::new("/tmp/cosmic_conf_symlink_canary"));
@@ -173,7 +173,7 @@ fn well_formed_archive_still_installs() {
let installer = Installer::with_paths(&data, &home); let installer = Installer::with_paths(&data, &home);
let plan = installer let plan = installer
.plan(&theme_dir, Some(&source_dir), "Evil", true) .plan(&theme_dir, Some(&source_dir), "Evil", None, true)
.expect("a well-formed archive must plan cleanly"); .expect("a well-formed archive must plan cleanly");
installer.apply(&plan).expect("and must apply"); installer.apply(&plan).expect("and must apply");