Skip to main content

lingxia_app_context/
lib.rs

1use semver::Version;
2use serde::{Deserialize, Serialize};
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6use thiserror::Error;
7
8static APP_CONFIG: OnceLock<AppConfig> = OnceLock::new();
9const APP_STATE_DIR: &str = "app_state";
10
11#[derive(Debug, Error)]
12pub enum AppContextError {
13    #[error("invalid app.json: {0}")]
14    InvalidJson(String),
15    #[error("invalid app config: {0}")]
16    InvalidConfig(String),
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
20pub struct AppConfig {
21    #[serde(rename = "productName")]
22    pub product_name: String,
23    #[serde(rename = "productVersion")]
24    pub product_version: String,
25
26    #[serde(rename = "lingxiaId", default)]
27    pub lingxia_id: Option<String>,
28
29    #[serde(rename = "apiServer", default)]
30    pub api_server: Option<String>,
31
32    #[serde(rename = "homeLxAppID")]
33    pub home_lxapp_appid: String,
34
35    #[serde(rename = "homeLxAppVersion")]
36    pub home_lxapp_version: String,
37
38    #[serde(rename = "cacheMaxAgeDays", default = "default_cache_max_age_days")]
39    pub cache_max_age_days: u64,
40
41    #[serde(rename = "cacheMaxSizeMB", default = "default_cache_max_size_mb")]
42    pub cache_max_size_mb: u64,
43
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub panels: Option<PanelsConfig>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
49pub struct PanelsConfig {
50    #[serde(default, skip_serializing_if = "Vec::is_empty")]
51    pub items: Vec<PanelItem>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
55#[serde(rename_all = "lowercase")]
56pub enum PanelPosition {
57    Left,
58    Right,
59    Bottom,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
63pub struct PanelItem {
64    pub id: String,
65    pub label: String,
66    pub icon: String,
67    #[serde(default = "default_panel_position")]
68    pub position: PanelPosition,
69    pub content: PanelContent,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
73pub struct PanelContent {
74    #[serde(rename = "appId")]
75    pub app_id: String,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub path: Option<String>,
78}
79
80fn default_cache_max_age_days() -> u64 {
81    7
82}
83
84fn default_cache_max_size_mb() -> u64 {
85    1024
86}
87
88fn default_panel_position() -> PanelPosition {
89    PanelPosition::Right
90}
91
92impl AppConfig {
93    pub fn parse_and_validate(content: &str) -> Result<Self, AppContextError> {
94        let config: Self = serde_json::from_str(content).map_err(|e| {
95            AppContextError::InvalidJson(format!("Failed to parse app.json: {}", e))
96        })?;
97        config.validate()?;
98        Ok(config)
99    }
100
101    fn validate(&self) -> Result<(), AppContextError> {
102        if self.product_name.is_empty() {
103            return Err(AppContextError::InvalidConfig(
104                "productName is mandatory and cannot be empty".to_string(),
105            ));
106        }
107        if self.product_version.is_empty() {
108            return Err(AppContextError::InvalidConfig(
109                "productVersion is mandatory and cannot be empty".to_string(),
110            ));
111        }
112        Version::parse(&self.product_version).map_err(|_| {
113            AppContextError::InvalidConfig(
114                "productVersion must be a semantic version (major.minor.patch)".to_string(),
115            )
116        })?;
117        if self.home_lxapp_appid.is_empty() {
118            return Err(AppContextError::InvalidConfig(
119                "homeLxAppID is mandatory and cannot be empty".to_string(),
120            ));
121        }
122        if self.home_lxapp_version.is_empty() {
123            return Err(AppContextError::InvalidConfig(
124                "homeLxAppVersion is mandatory and cannot be empty".to_string(),
125            ));
126        }
127        Version::parse(&self.home_lxapp_version).map_err(|_| {
128            AppContextError::InvalidConfig(
129                "homeLxAppVersion must be a semantic version (major.minor.patch)".to_string(),
130            )
131        })?;
132        validate_panels(self.panels.as_ref())
133    }
134}
135
136pub fn set_app_config(config: AppConfig) -> Result<(), AppContextError> {
137    if let Some(existing) = APP_CONFIG.get() {
138        if existing == &config {
139            return Ok(());
140        }
141        return Err(AppContextError::InvalidConfig(
142            "app config is already initialized with different values".to_string(),
143        ));
144    }
145
146    APP_CONFIG
147        .set(config)
148        .map_err(|_| {
149            AppContextError::InvalidConfig(
150                "app config was initialized concurrently with different values".to_string(),
151            )
152        })
153        .map(|_| ())
154}
155
156pub fn app_config() -> Option<&'static AppConfig> {
157    APP_CONFIG.get()
158}
159
160pub fn product_name() -> Option<&'static str> {
161    APP_CONFIG.get().map(|c| c.product_name.as_str())
162}
163
164pub fn product_version() -> Option<&'static str> {
165    APP_CONFIG.get().map(|c| c.product_version.as_str())
166}
167
168pub fn lingxia_id() -> Option<&'static str> {
169    APP_CONFIG
170        .get()
171        .and_then(|c| c.lingxia_id.as_deref())
172        .filter(|s| !s.is_empty())
173}
174
175pub fn cache_max_age_days() -> u64 {
176    APP_CONFIG
177        .get()
178        .map(|c| c.cache_max_age_days)
179        .unwrap_or_else(default_cache_max_age_days)
180}
181
182pub fn cache_max_size_bytes() -> u64 {
183    const MIB: u64 = 1024 * 1024;
184    APP_CONFIG
185        .get()
186        .map(|c| c.cache_max_size_mb.saturating_mul(MIB))
187        .unwrap_or_else(|| default_cache_max_size_mb().saturating_mul(MIB))
188}
189
190pub fn app_state_dir(app_data_dir: &Path) -> PathBuf {
191    app_data_dir.join(APP_STATE_DIR)
192}
193
194pub fn app_state_file(app_data_dir: &Path, name: &str) -> PathBuf {
195    app_state_dir(app_data_dir).join(name)
196}
197
198fn validate_panels(panels: Option<&PanelsConfig>) -> Result<(), AppContextError> {
199    let Some(panels) = panels else {
200        return Ok(());
201    };
202
203    let mut ids = HashSet::new();
204    let mut positions = HashSet::new();
205    let mut app_ids = HashSet::new();
206
207    for item in &panels.items {
208        if item.id.is_empty() {
209            return Err(AppContextError::InvalidConfig(
210                "panels.items[].id cannot be empty".to_string(),
211            ));
212        }
213        if item.label.is_empty() {
214            return Err(AppContextError::InvalidConfig(format!(
215                "panel '{}' label cannot be empty",
216                item.id
217            )));
218        }
219        if item.content.app_id.is_empty() {
220            return Err(AppContextError::InvalidConfig(format!(
221                "panel '{}' content.appId cannot be empty",
222                item.id
223            )));
224        }
225        if !ids.insert(item.id.clone()) {
226            return Err(AppContextError::InvalidConfig(format!(
227                "duplicate panel id '{}'",
228                item.id
229            )));
230        }
231        if !positions.insert(item.position) {
232            return Err(AppContextError::InvalidConfig(format!(
233                "only one panel is supported at position '{}'",
234                panel_position_name(item.position)
235            )));
236        }
237        if !app_ids.insert(item.content.app_id.clone()) {
238            return Err(AppContextError::InvalidConfig(format!(
239                "panel appId '{}' must be unique",
240                item.content.app_id
241            )));
242        }
243    }
244
245    Ok(())
246}
247
248fn panel_position_name(position: PanelPosition) -> &'static str {
249    match position {
250        PanelPosition::Left => "left",
251        PanelPosition::Right => "right",
252        PanelPosition::Bottom => "bottom",
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::{AppConfig, AppContextError, set_app_config};
259
260    fn test_config(product_name: &str) -> AppConfig {
261        AppConfig {
262            product_name: product_name.to_string(),
263            product_version: "1.0.0".to_string(),
264            lingxia_id: Some("lingxia".to_string()),
265            api_server: None,
266            home_lxapp_appid: "home".to_string(),
267            home_lxapp_version: "1.0.0".to_string(),
268            cache_max_age_days: 7,
269            cache_max_size_mb: 1024,
270            panels: None,
271        }
272    }
273
274    #[test]
275    fn set_app_config_rejects_mismatched_value_after_initialization() {
276        let cfg = test_config("LingXia");
277        assert!(set_app_config(cfg.clone()).is_ok());
278        assert!(set_app_config(cfg).is_ok());
279        let err = set_app_config(test_config("Other")).unwrap_err();
280        assert!(matches!(err, AppContextError::InvalidConfig(_)));
281    }
282}