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}