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