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