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