| 1 |
|
- |
//! Theme loading commands — reads TOML themes from bundled resources, dev fallback, and user config.
|
|
1 |
+ |
//! Theme loading commands — thin Tauri wrappers over `theme_common`.
|
| 2 |
2 |
|
|
| 3 |
|
- |
use serde::Serialize;
|
| 4 |
|
- |
use std::collections::HashMap;
|
| 5 |
3 |
|
use std::path::PathBuf;
|
| 6 |
4 |
|
use tauri::{AppHandle, Manager};
|
| 7 |
5 |
|
use tracing::instrument;
|
| 8 |
6 |
|
|
| 9 |
|
- |
use super::{ApiError, ResultApiError};
|
|
7 |
+ |
use super::ApiError;
|
|
8 |
+ |
|
|
9 |
+ |
pub use theme_common::{ThemeColors, ThemeMeta};
|
| 10 |
10 |
|
|
| 11 |
11 |
|
/// Returns theme directories in priority order (later overrides earlier by ID).
|
| 12 |
12 |
|
/// Each entry is `(path, is_custom)`.
|
| 22 |
22 |
|
}
|
| 23 |
23 |
|
|
| 24 |
24 |
|
// 2. Dev fallback: CARGO_MANIFEST_DIR → 3 parents up → Git/themes/
|
| 25 |
|
- |
let dev_themes = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
| 26 |
|
- |
.parent() // goingson/
|
| 27 |
|
- |
.and_then(|p| p.parent()) // active/
|
| 28 |
|
- |
.and_then(|p| p.parent()) // Git/
|
| 29 |
|
- |
.map(|p| p.join("themes"));
|
| 30 |
|
- |
if let Some(ref dev_path) = dev_themes {
|
| 31 |
|
- |
if dev_path.is_dir() {
|
| 32 |
|
- |
dirs.push((dev_path.clone(), false));
|
| 33 |
|
- |
}
|
|
25 |
+ |
if let Some(dev_path) = theme_common::dev_themes_dir(env!("CARGO_MANIFEST_DIR").as_ref(), 3) {
|
|
26 |
+ |
dirs.push((dev_path, false));
|
| 34 |
27 |
|
}
|
| 35 |
28 |
|
|
| 36 |
29 |
|
// 3. User custom themes (highest priority)
|
| 44 |
37 |
|
dirs
|
| 45 |
38 |
|
}
|
| 46 |
39 |
|
|
| 47 |
|
- |
fn validate_theme_id(id: &str) -> Result<(), ApiError> {
|
| 48 |
|
- |
if !id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
| 49 |
|
- |
return Err(ApiError::bad_request(format!("Invalid theme ID: {}", id)));
|
| 50 |
|
- |
}
|
| 51 |
|
- |
Ok(())
|
| 52 |
|
- |
}
|
| 53 |
|
- |
|
| 54 |
|
- |
#[derive(Debug, Serialize)]
|
| 55 |
|
- |
#[serde(rename_all = "camelCase")]
|
| 56 |
|
- |
pub struct ThemeMeta {
|
| 57 |
|
- |
pub id: String,
|
| 58 |
|
- |
pub name: String,
|
| 59 |
|
- |
pub variant: String,
|
| 60 |
|
- |
pub is_custom: bool,
|
| 61 |
|
- |
}
|
| 62 |
|
- |
|
| 63 |
|
- |
#[derive(Debug, Serialize)]
|
| 64 |
|
- |
#[serde(rename_all = "camelCase")]
|
| 65 |
|
- |
pub struct ThemeColors {
|
| 66 |
|
- |
pub meta: ThemeMeta,
|
| 67 |
|
- |
pub colors: HashMap<String, String>,
|
| 68 |
|
- |
}
|
| 69 |
|
- |
|
| 70 |
|
- |
/// Parse a TOML theme file into its meta fields.
|
| 71 |
|
- |
fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
|
| 72 |
|
- |
let meta = table.get("meta").and_then(|m| m.as_table());
|
| 73 |
|
- |
let name = meta
|
| 74 |
|
- |
.and_then(|m| m.get("name"))
|
| 75 |
|
- |
.and_then(|v| v.as_str())
|
| 76 |
|
- |
.unwrap_or(id)
|
| 77 |
|
- |
.to_string();
|
| 78 |
|
- |
let variant = meta
|
| 79 |
|
- |
.and_then(|m| m.get("variant"))
|
| 80 |
|
- |
.and_then(|v| v.as_str())
|
| 81 |
|
- |
.unwrap_or("dark")
|
| 82 |
|
- |
.to_string();
|
| 83 |
|
- |
|
| 84 |
|
- |
ThemeMeta {
|
| 85 |
|
- |
id: id.to_string(),
|
| 86 |
|
- |
name,
|
| 87 |
|
- |
variant,
|
| 88 |
|
- |
is_custom,
|
| 89 |
|
- |
}
|
| 90 |
|
- |
}
|
| 91 |
|
- |
|
| 92 |
40 |
|
#[tauri::command]
|
| 93 |
41 |
|
#[instrument(skip_all)]
|
| 94 |
42 |
|
pub fn list_themes(app: AppHandle) -> Result<Vec<ThemeMeta>, ApiError> {
|
| 95 |
43 |
|
let dirs = theme_dirs(&app);
|
| 96 |
|
- |
// Collect themes, later dirs override earlier by ID
|
| 97 |
|
- |
let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
|
| 98 |
|
- |
|
| 99 |
|
- |
for (dir, is_custom) in &dirs {
|
| 100 |
|
- |
let entries = match std::fs::read_dir(dir) {
|
| 101 |
|
- |
Ok(e) => e,
|
| 102 |
|
- |
Err(_) => continue,
|
| 103 |
|
- |
};
|
| 104 |
|
- |
|
| 105 |
|
- |
for entry in entries {
|
| 106 |
|
- |
let entry = match entry {
|
| 107 |
|
- |
Ok(e) => e,
|
| 108 |
|
- |
Err(_) => continue,
|
| 109 |
|
- |
};
|
| 110 |
|
- |
let path = entry.path();
|
| 111 |
|
- |
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
|
| 112 |
|
- |
continue;
|
| 113 |
|
- |
}
|
| 114 |
|
- |
|
| 115 |
|
- |
let id = path
|
| 116 |
|
- |
.file_stem()
|
| 117 |
|
- |
.and_then(|s| s.to_str())
|
| 118 |
|
- |
.unwrap_or_default()
|
| 119 |
|
- |
.to_string();
|
| 120 |
|
- |
|
| 121 |
|
- |
let content = match std::fs::read_to_string(&path) {
|
| 122 |
|
- |
Ok(c) => c,
|
| 123 |
|
- |
Err(_) => continue,
|
| 124 |
|
- |
};
|
| 125 |
|
- |
let table: toml::Table = match content.parse() {
|
| 126 |
|
- |
Ok(t) => t,
|
| 127 |
|
- |
Err(_) => continue,
|
| 128 |
|
- |
};
|
| 129 |
|
- |
|
| 130 |
|
- |
seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
|
| 131 |
|
- |
}
|
| 132 |
|
- |
}
|
| 133 |
|
- |
|
| 134 |
|
- |
let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
|
| 135 |
|
- |
themes.sort_by(|a, b| a.name.cmp(&b.name));
|
| 136 |
|
- |
Ok(themes)
|
| 137 |
|
- |
}
|
| 138 |
|
- |
|
| 139 |
|
- |
/// Find a theme file by ID, checking directories in reverse priority order
|
| 140 |
|
- |
/// (user custom first, then dev, then bundled).
|
| 141 |
|
- |
fn find_theme_path(app: &AppHandle, id: &str) -> Result<(PathBuf, bool), ApiError> {
|
| 142 |
|
- |
let dirs = theme_dirs(app);
|
| 143 |
|
- |
let filename = format!("{}.toml", id);
|
| 144 |
|
- |
|
| 145 |
|
- |
// Check in reverse so highest-priority dir wins
|
| 146 |
|
- |
for (dir, is_custom) in dirs.iter().rev() {
|
| 147 |
|
- |
let path = dir.join(&filename);
|
| 148 |
|
- |
if path.is_file() {
|
| 149 |
|
- |
return Ok((path, *is_custom));
|
| 150 |
|
- |
}
|
| 151 |
|
- |
}
|
| 152 |
|
- |
|
| 153 |
|
- |
Err(ApiError::not_found("theme", id))
|
|
44 |
+ |
Ok(theme_common::list_themes_from_dirs(&dirs))
|
| 154 |
45 |
|
}
|
| 155 |
46 |
|
|
| 156 |
47 |
|
#[tauri::command]
|
| 157 |
48 |
|
#[instrument(skip_all)]
|
| 158 |
49 |
|
pub fn get_theme(app: AppHandle, id: String) -> Result<ThemeColors, ApiError> {
|
| 159 |
|
- |
validate_theme_id(&id)?;
|
| 160 |
|
- |
|
| 161 |
|
- |
let (path, is_custom) = find_theme_path(&app, &id)?;
|
| 162 |
|
- |
let content = std::fs::read_to_string(&path)
|
| 163 |
|
- |
.map_api_err(
|
| 164 |
|
- |
&format!("Failed to read {}", path.display()),
|
| 165 |
|
- |
ApiError::internal,
|
| 166 |
|
- |
)?;
|
| 167 |
|
- |
let table: toml::Table = content
|
| 168 |
|
- |
.parse()
|
| 169 |
|
- |
.map_err(|e| ApiError::internal(format!("Failed to parse {}: {}", path.display(), e)))?;
|
| 170 |
|
- |
|
| 171 |
|
- |
let meta = parse_meta(&id, &table, is_custom);
|
| 172 |
|
- |
|
| 173 |
|
- |
let mut colors = HashMap::new();
|
| 174 |
|
- |
for section in &["background", "foreground", "accent", "border"] {
|
| 175 |
|
- |
if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
|
| 176 |
|
- |
for (key, val) in sect {
|
| 177 |
|
- |
if let Some(color) = val.as_str() {
|
| 178 |
|
- |
colors.insert(format!("{}.{}", section, key), color.to_string());
|
| 179 |
|
- |
}
|
| 180 |
|
- |
}
|
| 181 |
|
- |
}
|
| 182 |
|
- |
}
|
| 183 |
|
- |
|
| 184 |
|
- |
Ok(ThemeColors { meta, colors })
|
|
50 |
+ |
let dirs = theme_dirs(&app);
|
|
51 |
+ |
theme_common::load_theme(&dirs, &id).map_err(|e| ApiError::internal(e))
|
| 185 |
52 |
|
}
|
| 186 |
53 |
|
|
| 187 |
54 |
|
#[cfg(test)]
|
| 188 |
55 |
|
mod tests {
|
| 189 |
|
- |
use super::*;
|
| 190 |
|
- |
|
| 191 |
|
- |
// -- validate_theme_id tests --
|
| 192 |
|
- |
|
| 193 |
|
- |
#[test]
|
| 194 |
|
- |
fn validate_theme_id_alphanumeric() {
|
| 195 |
|
- |
assert!(validate_theme_id("darkmode").is_ok());
|
| 196 |
|
- |
assert!(validate_theme_id("Theme123").is_ok());
|
| 197 |
|
- |
}
|
| 198 |
|
- |
|
| 199 |
|
- |
#[test]
|
| 200 |
|
- |
fn validate_theme_id_hyphens_underscores() {
|
| 201 |
|
- |
assert!(validate_theme_id("dark-mode").is_ok());
|
| 202 |
|
- |
assert!(validate_theme_id("my_theme_v2").is_ok());
|
| 203 |
|
- |
assert!(validate_theme_id("a-b_c-d").is_ok());
|
| 204 |
|
- |
}
|
| 205 |
|
- |
|
| 206 |
|
- |
#[test]
|
| 207 |
|
- |
fn validate_theme_id_rejects_spaces() {
|
| 208 |
|
- |
assert!(validate_theme_id("has space").is_err());
|
| 209 |
|
- |
}
|
| 210 |
|
- |
|
| 211 |
|
- |
#[test]
|
| 212 |
|
- |
fn validate_theme_id_rejects_path_traversal() {
|
| 213 |
|
- |
assert!(validate_theme_id("../etc/passwd").is_err());
|
| 214 |
|
- |
assert!(validate_theme_id("foo/bar").is_err());
|
| 215 |
|
- |
}
|
| 216 |
|
- |
|
| 217 |
|
- |
#[test]
|
| 218 |
|
- |
fn validate_theme_id_rejects_special_chars() {
|
| 219 |
|
- |
assert!(validate_theme_id("evil<script>").is_err());
|
| 220 |
|
- |
assert!(validate_theme_id("theme;drop").is_err());
|
| 221 |
|
- |
assert!(validate_theme_id("theme.toml").is_err());
|
| 222 |
|
- |
}
|
| 223 |
|
- |
|
| 224 |
|
- |
#[test]
|
| 225 |
|
- |
fn validate_theme_id_empty_is_valid() {
|
| 226 |
|
- |
assert!(validate_theme_id("").is_ok(), "empty string has no invalid chars");
|
| 227 |
|
- |
}
|
| 228 |
|
- |
|
| 229 |
|
- |
// -- parse_meta tests --
|
| 230 |
|
- |
|
| 231 |
|
- |
#[test]
|
| 232 |
|
- |
fn parse_meta_with_name_and_variant() {
|
| 233 |
|
- |
let toml_str = r#"
|
| 234 |
|
- |
[meta]
|
| 235 |
|
- |
name = "Solarized Dark"
|
| 236 |
|
- |
variant = "light"
|
| 237 |
|
- |
"#;
|
| 238 |
|
- |
let table: toml::Table = toml_str.parse().unwrap();
|
| 239 |
|
- |
let meta = parse_meta("solarized", &table, false);
|
| 240 |
|
- |
assert_eq!(meta.id, "solarized");
|
| 241 |
|
- |
assert_eq!(meta.name, "Solarized Dark");
|
| 242 |
|
- |
assert_eq!(meta.variant, "light");
|
| 243 |
|
- |
assert!(!meta.is_custom);
|
| 244 |
|
- |
}
|
| 245 |
|
- |
|
| 246 |
|
- |
#[test]
|
| 247 |
|
- |
fn parse_meta_defaults_to_id_and_dark() {
|
| 248 |
|
- |
let table: toml::Table = "".parse().unwrap();
|
| 249 |
|
- |
let meta = parse_meta("fallback", &table, true);
|
| 250 |
|
- |
assert_eq!(meta.name, "fallback");
|
| 251 |
|
- |
assert_eq!(meta.variant, "dark");
|
| 252 |
|
- |
assert!(meta.is_custom);
|
| 253 |
|
- |
}
|
| 254 |
|
- |
|
| 255 |
|
- |
#[test]
|
| 256 |
|
- |
fn parse_meta_missing_variant_defaults_dark() {
|
| 257 |
|
- |
let toml_str = r#"
|
| 258 |
|
- |
[meta]
|
| 259 |
|
- |
name = "Minimal"
|
| 260 |
|
- |
"#;
|
| 261 |
|
- |
let table: toml::Table = toml_str.parse().unwrap();
|
| 262 |
|
- |
let meta = parse_meta("minimal", &table, false);
|
| 263 |
|
- |
assert_eq!(meta.name, "Minimal");
|
| 264 |
|
- |
assert_eq!(meta.variant, "dark");
|
| 265 |
|
- |
}
|
|
56 |
+ |
// Core theme logic tests live in theme-common crate.
|
|
57 |
+ |
// App-specific tests here only if needed for theme_dirs behavior.
|
| 266 |
58 |
|
}
|