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}