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 = "lingxiaServer", default)]
30    pub lingxia_server: Option<String>,
31
32    #[serde(rename = "homeAppId")]
33    pub home_app_id: String,
34
35    #[serde(rename = "homeAppVersion")]
36    pub home_app_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 storage: Option<StorageConfig>,
46
47    #[serde(rename = "devWsUrl", default, skip_serializing_if = "Option::is_none")]
48    pub dev_ws_url: Option<String>,
49
50    #[serde(rename = "appLinks", default, skip_serializing_if = "Option::is_none")]
51    pub app_links: Option<AppLinksConfig>,
52
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub capabilities: Option<CapabilitiesConfig>,
55
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub panels: Option<PanelsConfig>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
61#[serde(rename_all = "camelCase")]
62pub struct CapabilitiesConfig {
63    #[serde(default)]
64    pub notifications: bool,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
68pub struct AppLinksConfig {
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub hosts: Vec<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct StorageConfig {
76    #[serde(rename = "tempMaxSizeMB")]
77    #[serde(default = "default_temp_max_size_mb")]
78    pub temp_max_size_mb: u64,
79    #[serde(default = "default_cache_max_age_days")]
80    pub cache_max_age_days: u64,
81    #[serde(rename = "cacheMaxSizeMB")]
82    #[serde(default = "default_cache_max_size_mb")]
83    pub cache_max_size_mb: u64,
84    #[serde(rename = "dataMaxSizeMB")]
85    #[serde(default = "default_data_max_size_mb")]
86    pub data_max_size_mb: u64,
87    #[serde(rename = "appStorageMaxSizeMB")]
88    #[serde(default = "default_app_storage_max_size_mb")]
89    pub app_storage_max_size_mb: u64,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
93pub struct PanelsConfig {
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub items: Vec<PanelItem>,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
99#[serde(rename_all = "lowercase")]
100pub enum PanelPosition {
101    Left,
102    Right,
103    Bottom,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
107pub struct PanelItem {
108    pub id: String,
109    pub label: String,
110    pub icon: String,
111    #[serde(default = "default_panel_position")]
112    pub position: PanelPosition,
113    pub content: PanelContent,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
117pub struct PanelContent {
118    #[serde(rename = "appId")]
119    pub app_id: String,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub path: Option<String>,
122}
123
124fn default_cache_max_age_days() -> u64 {
125    7
126}
127
128fn default_cache_max_size_mb() -> u64 {
129    2048
130}
131
132fn default_temp_max_size_mb() -> u64 {
133    1024
134}
135
136fn default_data_max_size_mb() -> u64 {
137    4096
138}
139
140fn default_app_storage_max_size_mb() -> u64 {
141    16384
142}
143
144fn default_panel_position() -> PanelPosition {
145    PanelPosition::Right
146}
147
148impl AppConfig {
149    pub fn parse_and_validate(content: &str) -> Result<Self, AppContextError> {
150        let config: Self = serde_json::from_str(content).map_err(|e| {
151            AppContextError::InvalidJson(format!("Failed to parse app.json: {}", e))
152        })?;
153        config.validate()?;
154        Ok(config)
155    }
156
157    fn validate(&self) -> Result<(), AppContextError> {
158        if self.product_name.is_empty() {
159            return Err(AppContextError::InvalidConfig(
160                "productName is mandatory and cannot be empty".to_string(),
161            ));
162        }
163        if self.product_version.is_empty() {
164            return Err(AppContextError::InvalidConfig(
165                "productVersion is mandatory and cannot be empty".to_string(),
166            ));
167        }
168        Version::parse(&self.product_version).map_err(|_| {
169            AppContextError::InvalidConfig(
170                "productVersion must be a semantic version (major.minor.patch)".to_string(),
171            )
172        })?;
173        if self.home_app_id.is_empty() {
174            return Err(AppContextError::InvalidConfig(
175                "homeAppId is mandatory and cannot be empty".to_string(),
176            ));
177        }
178        if self.home_app_version.is_empty() {
179            return Err(AppContextError::InvalidConfig(
180                "homeAppVersion is mandatory and cannot be empty".to_string(),
181            ));
182        }
183        Version::parse(&self.home_app_version).map_err(|_| {
184            AppContextError::InvalidConfig(
185                "homeAppVersion must be a semantic version (major.minor.patch)".to_string(),
186            )
187        })?;
188        validate_panels(self.panels.as_ref())
189    }
190}
191
192pub fn set_app_config(config: AppConfig) -> Result<(), AppContextError> {
193    if let Some(existing) = APP_CONFIG.get() {
194        if existing == &config {
195            return Ok(());
196        }
197        return Err(AppContextError::InvalidConfig(
198            "app config is already initialized with different values".to_string(),
199        ));
200    }
201
202    APP_CONFIG
203        .set(config)
204        .map_err(|_| {
205            AppContextError::InvalidConfig(
206                "app config was initialized concurrently with different values".to_string(),
207            )
208        })
209        .map(|_| ())
210}
211
212pub fn app_config() -> Option<&'static AppConfig> {
213    APP_CONFIG.get()
214}
215
216pub fn product_name() -> Option<&'static str> {
217    APP_CONFIG.get().map(|c| c.product_name.as_str())
218}
219
220pub fn home_app_id() -> Option<&'static str> {
221    APP_CONFIG.get().map(|c| c.home_app_id.as_str())
222}
223
224pub fn home_app_version() -> Option<&'static str> {
225    APP_CONFIG.get().map(|c| c.home_app_version.as_str())
226}
227
228pub fn product_version() -> Option<&'static str> {
229    APP_CONFIG.get().map(|c| c.product_version.as_str())
230}
231
232pub fn lingxia_id() -> Option<&'static str> {
233    APP_CONFIG
234        .get()
235        .and_then(|c| c.lingxia_id.as_deref())
236        .filter(|s| !s.is_empty())
237}
238
239pub fn notifications_enabled() -> bool {
240    APP_CONFIG
241        .get()
242        .and_then(|c| c.capabilities.as_ref())
243        .map(|capabilities| capabilities.notifications)
244        .unwrap_or(false)
245}
246
247pub fn cache_max_age_days() -> u64 {
248    APP_CONFIG
249        .get()
250        .map(|c| {
251            c.storage
252                .as_ref()
253                .map(|storage| storage.cache_max_age_days)
254                .unwrap_or(c.cache_max_age_days)
255        })
256        .unwrap_or_else(default_cache_max_age_days)
257}
258
259pub fn temp_max_size_bytes() -> u64 {
260    const MIB: u64 = 1024 * 1024;
261    APP_CONFIG
262        .get()
263        .and_then(|c| c.storage.as_ref().map(|storage| storage.temp_max_size_mb))
264        .unwrap_or_else(default_temp_max_size_mb)
265        .saturating_mul(MIB)
266}
267
268pub fn cache_max_size_bytes() -> u64 {
269    const MIB: u64 = 1024 * 1024;
270    APP_CONFIG
271        .get()
272        .map(|c| {
273            c.storage
274                .as_ref()
275                .map(|storage| storage.cache_max_size_mb)
276                .unwrap_or(c.cache_max_size_mb)
277        })
278        .unwrap_or_else(default_cache_max_size_mb)
279        .saturating_mul(MIB)
280}
281
282pub fn data_max_size_bytes() -> u64 {
283    const MIB: u64 = 1024 * 1024;
284    APP_CONFIG
285        .get()
286        .and_then(|c| c.storage.as_ref().map(|storage| storage.data_max_size_mb))
287        .unwrap_or_else(default_data_max_size_mb)
288        .saturating_mul(MIB)
289}
290
291pub fn app_storage_max_size_bytes() -> u64 {
292    const MIB: u64 = 1024 * 1024;
293    APP_CONFIG
294        .get()
295        .and_then(|c| {
296            c.storage
297                .as_ref()
298                .map(|storage| storage.app_storage_max_size_mb)
299        })
300        .unwrap_or_else(default_app_storage_max_size_mb)
301        .saturating_mul(MIB)
302}
303
304pub fn app_state_dir(app_data_dir: &Path) -> PathBuf {
305    app_data_dir.join(APP_STATE_DIR)
306}
307
308pub fn app_state_file(app_data_dir: &Path, name: &str) -> PathBuf {
309    app_state_dir(app_data_dir).join(name)
310}
311
312fn validate_panels(panels: Option<&PanelsConfig>) -> Result<(), AppContextError> {
313    let Some(panels) = panels else {
314        return Ok(());
315    };
316
317    let mut ids = HashSet::new();
318    let mut positions = HashSet::new();
319    let mut app_ids = HashSet::new();
320
321    for item in &panels.items {
322        if item.id.is_empty() {
323            return Err(AppContextError::InvalidConfig(
324                "panels.items[].id cannot be empty".to_string(),
325            ));
326        }
327        if item.label.is_empty() {
328            return Err(AppContextError::InvalidConfig(format!(
329                "panel '{}' label cannot be empty",
330                item.id
331            )));
332        }
333        if item.content.app_id.is_empty() {
334            return Err(AppContextError::InvalidConfig(format!(
335                "panel '{}' content.appId cannot be empty",
336                item.id
337            )));
338        }
339        if !ids.insert(item.id.clone()) {
340            return Err(AppContextError::InvalidConfig(format!(
341                "duplicate panel id '{}'",
342                item.id
343            )));
344        }
345        if !positions.insert(item.position) {
346            return Err(AppContextError::InvalidConfig(format!(
347                "only one panel is supported at position '{}'",
348                panel_position_name(item.position)
349            )));
350        }
351        if !app_ids.insert(item.content.app_id.clone()) {
352            return Err(AppContextError::InvalidConfig(format!(
353                "panel appId '{}' must be unique",
354                item.content.app_id
355            )));
356        }
357    }
358
359    Ok(())
360}
361
362fn panel_position_name(position: PanelPosition) -> &'static str {
363    match position {
364        PanelPosition::Left => "left",
365        PanelPosition::Right => "right",
366        PanelPosition::Bottom => "bottom",
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::{AppConfig, AppContextError, set_app_config};
373
374    fn test_config(product_name: &str) -> AppConfig {
375        AppConfig {
376            product_name: product_name.to_string(),
377            product_version: "1.0.0".to_string(),
378            lingxia_id: Some("lingxia".to_string()),
379            lingxia_server: None,
380            home_app_id: "home".to_string(),
381            home_app_version: "1.0.0".to_string(),
382            cache_max_age_days: 7,
383            cache_max_size_mb: 1024,
384            storage: None,
385            dev_ws_url: None,
386            app_links: None,
387            capabilities: None,
388            panels: None,
389        }
390    }
391
392    #[test]
393    fn set_app_config_rejects_mismatched_value_after_initialization() {
394        let cfg = test_config("LingXia");
395        assert!(set_app_config(cfg.clone()).is_ok());
396        assert!(set_app_config(cfg).is_ok());
397        let err = set_app_config(test_config("Other")).unwrap_err();
398        assert!(matches!(err, AppContextError::InvalidConfig(_)));
399    }
400}