use std::collections::BTreeMap;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
const CSS: &str = include_str!("../../console/assets/themes.css");
const CONSOLE_CSS: &str = include_str!("../../console/assets/console.css");
const STACKS: &[&str] = &[
"sans-serif",
"serif",
"monospace",
"system-ui",
"ui-sans-serif",
"ui-serif",
"ui-monospace",
];
#[derive(Debug, Serialize)]
pub struct Theme {
name: String,
base: String,
tokens: BTreeMap<String, String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ThemeDocument {
name: String,
#[serde(default = "default_base")]
base: String,
#[serde(default)]
tokens: BTreeMap<String, String>,
}
fn built_in_font(family: &str) -> bool {
let declaration = format!("font-family: \"{family}\";");
[CSS, CONSOLE_CSS].iter().any(|css| {
css.split("@font-face").skip(1).any(|face| {
face.split_once('}')
.is_some_and(|(block, _)| block.contains(&declaration))
})
})
}
fn default_base() -> String {
"focus-auto".into()
}
pub fn directory() -> Result<PathBuf> {
Ok(crate::config::home()?.join(".thirdshift/console/themes"))
}
fn identifier(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 100
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
fn defaults(base: &str) -> Result<BTreeMap<String, String>> {
ensure!(
super::theme_names().iter().any(|name| name == base),
"unknown base Theme: {base}"
);
let family = base.rsplit_once('-').expect("built-in mode").0;
let selector = format!(":root[data-theme^=\"{family}-\"] {{");
let block = CSS
.split_once(&selector)
.expect("built-in tokens")
.1
.split_once('}')
.expect("token block")
.0;
let mut tokens = BTreeMap::from([("activity-motion".into(), "none".into())]);
for declaration in block.split(';').filter_map(|s| s.trim().strip_prefix("--")) {
let (name, value) = declaration.split_once(':').expect("token declaration");
tokens.insert(name.into(), value.trim().into());
}
Ok(tokens)
}
fn hex(value: &str) -> bool {
value.strip_prefix('#').is_some_and(|digits| {
matches!(digits.len(), 3 | 4 | 6 | 8) && digits.bytes().all(|b| b.is_ascii_hexdigit())
})
}
fn color(value: &str) -> bool {
hex(value)
|| matches!(value, "transparent" | "currentColor")
|| value
.strip_prefix("light-dark(")
.and_then(|s| s.strip_suffix(')'))
.is_some_and(|pair| {
pair.split_once(',')
.is_some_and(|(a, b)| hex(a.trim()) && hex(b.trim()))
})
}
fn dimension(value: &str, unit: &str, min: f64, max: f64) -> bool {
value
.strip_suffix(unit)
.and_then(|s| s.parse::<f64>().ok())
.is_some_and(|n| n >= min && n <= max)
}
fn font_parts(value: &str) -> Result<Vec<&str>> {
let parts: Vec<_> = value
.split(',')
.map(|s| s.trim().trim_matches('"'))
.collect();
ensure!(
parts.iter().all(|part| !part.is_empty()
&& part.len() <= 100
&& part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b' ' | b'.' | b'-' | b'_'))),
"invalid font family or stack"
);
Ok(parts)
}
impl Theme {
pub fn parse(text: &str) -> Result<Self> {
ensure!(text.len() <= 16 * 1024, "Theme JSON exceeds 16 KiB");
let document: ThemeDocument = serde_json::from_str(text).context("invalid Theme JSON")?;
let mut theme = Self {
name: document.name,
base: document.base,
tokens: document.tokens,
};
ensure!(
identifier(&theme.name) && !matches!(theme.name.as_str(), "list" | "import" | "export"),
"invalid or reserved Theme name: {}",
theme.name
);
let mut tokens = defaults(&theme.base)?;
for (key, value) in &theme.tokens {
ensure!(tokens.contains_key(key), "unknown Theme token: {key}");
let builtin = super::THEME_FAMILIES.iter().any(|family| {
defaults(&format!("{family}-auto"))
.expect("built-in")
.get(key)
== Some(value)
});
let valid = builtin
|| match key.as_str() {
"font-head" | "font-body" | "font-mono" => font_parts(value).is_ok(),
"body-size" => dimension(value, "px", 10.0, 32.0),
"radius" => dimension(value, "px", 0.0, 32.0),
"head-weight" => value.parse::<u16>().is_ok_and(|n| (100..=900).contains(&n)),
"head-case" => matches!(
value.as_str(),
"none" | "uppercase" | "lowercase" | "capitalize"
),
"activity-speed" => dimension(value, "s", 0.1, 60.0),
"activity-decoration" | "activity-motion" | "panel-shadow" | "title-shadow" => {
false
}
_ => color(value),
};
ensure!(valid, "invalid Theme token {key}: {value}");
tokens.insert(key.clone(), value.clone());
}
theme.tokens = tokens;
if super::theme_names().contains(&theme.name) {
ensure!(
theme.base == theme.name && theme.tokens == defaults(&theme.name)?,
"built-in Theme {} cannot be replaced; choose a new name",
theme.name
);
}
Ok(theme)
}
pub fn export(name: &str) -> Result<Self> {
if super::theme_names().iter().any(|builtin| builtin == name) {
return Ok(Self {
name: name.into(),
base: name.into(),
tokens: defaults(name)?,
});
}
ensure!(identifier(name), "invalid Theme name: {name}");
let theme = Self::parse(&fs::read_to_string(
directory()?.join(format!("{name}.json")),
)?)?;
ensure!(
theme.name == name,
"Theme name does not match its filename: {name}"
);
Ok(theme)
}
pub fn save(&self) -> Result<()> {
if super::theme_names().contains(&self.name) {
return Ok(());
}
let root = directory()?;
fs::create_dir_all(&root)?;
let mut file = tempfile::NamedTempFile::new_in(&root)?;
file.write_all(serde_json::to_string_pretty(self)?.as_bytes())?;
file.persist(root.join(format!("{}.json", self.name)))?;
Ok(())
}
pub fn stylesheet(&self) -> String {
let mode = self.base.rsplit_once('-').expect("validated base").1;
let scheme = if mode == "auto" { "light dark" } else { mode };
let mut faces = String::new();
let mut declarations = format!(
":root[data-theme=\"{}\"] {{ color-scheme: {scheme};",
self.name
);
for (key, value) in &self.tokens {
let value = if key.starts_with("font-") {
let mut families = Vec::new();
for family in font_parts(value).expect("validated font") {
if built_in_font(family) {
families.push(format!("\"{family}\""));
} else if STACKS.contains(&family) {
families.push(family.into());
} else if font_type(family).is_some() && font(family).is_ok() {
faces.push_str(&format!("@font-face {{font-family: \"user-{family}\"; src: url(\"/assets/user-fonts/{family}\"); font-display: swap;}}\n"));
families.push(format!("\"user-{family}\""));
}
}
families.push(
if key == "font-mono" {
"monospace"
} else {
"sans-serif"
}
.into(),
);
families.join(", ")
} else {
value.clone()
};
declarations.push_str(&format!("--{key}: {value};"));
}
format!("{faces}{declarations}}}\n")
}
}
pub fn names() -> Result<Vec<String>> {
let mut names = super::theme_names();
let entries = match fs::read_dir(directory()?) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(names),
Err(error) => return Err(error.into()),
};
let mut custom = Vec::new();
for entry in entries {
let path = entry?.path();
if path.extension().is_some_and(|s| s == "json")
&& let Some(name) = path.file_stem().and_then(|s| s.to_str())
&& Theme::export(name).is_ok()
{
custom.push(name.to_owned());
}
}
custom.sort();
names.extend(custom);
Ok(names)
}
pub fn font_type(name: &str) -> Option<&'static str> {
if name.len() > 100
|| name.starts_with('.')
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
{
return None;
}
match name.rsplit_once('.')?.1 {
"woff2" => Some("font/woff2"),
"woff" => Some("font/woff"),
"otf" => Some("font/otf"),
"ttf" => Some("font/ttf"),
_ => None,
}
}
pub fn font(name: &str) -> Result<Vec<u8>> {
use std::os::fd::{AsRawFd, FromRawFd};
use std::os::unix::fs::OpenOptionsExt;
ensure!(font_type(name).is_some(), "unsupported font filename");
let folder = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(directory()?.join("fonts"))?;
let name = std::ffi::CString::new(name)?;
let fd = unsafe {
libc::openat(
folder.as_raw_fd(),
name.as_ptr(),
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error().into());
}
let mut file = unsafe { fs::File::from_raw_fd(fd) };
ensure!(file.metadata()?.is_file(), "font is not a regular file");
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(bytes)
}