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