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, 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 #[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
268pub 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}