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 = "apiServer", default)]
30 pub api_server: Option<String>,
31
32 #[serde(rename = "homeLxAppID")]
33 pub home_lxapp_appid: String,
34
35 #[serde(rename = "homeLxAppVersion")]
36 pub home_lxapp_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 panels: Option<PanelsConfig>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
49pub struct PanelsConfig {
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub items: Vec<PanelItem>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
55#[serde(rename_all = "lowercase")]
56pub enum PanelPosition {
57 Left,
58 Right,
59 Bottom,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
63pub struct PanelItem {
64 pub id: String,
65 pub label: String,
66 pub icon: String,
67 #[serde(default = "default_panel_position")]
68 pub position: PanelPosition,
69 pub content: PanelContent,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
73pub struct PanelContent {
74 #[serde(rename = "appId")]
75 pub app_id: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub path: Option<String>,
78}
79
80fn default_cache_max_age_days() -> u64 {
81 7
82}
83
84fn default_cache_max_size_mb() -> u64 {
85 1024
86}
87
88fn default_panel_position() -> PanelPosition {
89 PanelPosition::Right
90}
91
92impl AppConfig {
93 pub fn parse_and_validate(content: &str) -> Result<Self, AppContextError> {
94 let config: Self = serde_json::from_str(content).map_err(|e| {
95 AppContextError::InvalidJson(format!("Failed to parse app.json: {}", e))
96 })?;
97 config.validate()?;
98 Ok(config)
99 }
100
101 fn validate(&self) -> Result<(), AppContextError> {
102 if self.product_name.is_empty() {
103 return Err(AppContextError::InvalidConfig(
104 "productName is mandatory and cannot be empty".to_string(),
105 ));
106 }
107 if self.product_version.is_empty() {
108 return Err(AppContextError::InvalidConfig(
109 "productVersion is mandatory and cannot be empty".to_string(),
110 ));
111 }
112 Version::parse(&self.product_version).map_err(|_| {
113 AppContextError::InvalidConfig(
114 "productVersion must be a semantic version (major.minor.patch)".to_string(),
115 )
116 })?;
117 if self.home_lxapp_appid.is_empty() {
118 return Err(AppContextError::InvalidConfig(
119 "homeLxAppID is mandatory and cannot be empty".to_string(),
120 ));
121 }
122 if self.home_lxapp_version.is_empty() {
123 return Err(AppContextError::InvalidConfig(
124 "homeLxAppVersion is mandatory and cannot be empty".to_string(),
125 ));
126 }
127 Version::parse(&self.home_lxapp_version).map_err(|_| {
128 AppContextError::InvalidConfig(
129 "homeLxAppVersion must be a semantic version (major.minor.patch)".to_string(),
130 )
131 })?;
132 validate_panels(self.panels.as_ref())
133 }
134}
135
136pub fn set_app_config(config: AppConfig) -> Result<(), AppContextError> {
137 if let Some(existing) = APP_CONFIG.get() {
138 if existing == &config {
139 return Ok(());
140 }
141 return Err(AppContextError::InvalidConfig(
142 "app config is already initialized with different values".to_string(),
143 ));
144 }
145
146 APP_CONFIG
147 .set(config)
148 .map_err(|_| {
149 AppContextError::InvalidConfig(
150 "app config was initialized concurrently with different values".to_string(),
151 )
152 })
153 .map(|_| ())
154}
155
156pub fn app_config() -> Option<&'static AppConfig> {
157 APP_CONFIG.get()
158}
159
160pub fn product_name() -> Option<&'static str> {
161 APP_CONFIG.get().map(|c| c.product_name.as_str())
162}
163
164pub fn product_version() -> Option<&'static str> {
165 APP_CONFIG.get().map(|c| c.product_version.as_str())
166}
167
168pub fn lingxia_id() -> Option<&'static str> {
169 APP_CONFIG
170 .get()
171 .and_then(|c| c.lingxia_id.as_deref())
172 .filter(|s| !s.is_empty())
173}
174
175pub fn cache_max_age_days() -> u64 {
176 APP_CONFIG
177 .get()
178 .map(|c| c.cache_max_age_days)
179 .unwrap_or_else(default_cache_max_age_days)
180}
181
182pub fn cache_max_size_bytes() -> u64 {
183 const MIB: u64 = 1024 * 1024;
184 APP_CONFIG
185 .get()
186 .map(|c| c.cache_max_size_mb.saturating_mul(MIB))
187 .unwrap_or_else(|| default_cache_max_size_mb().saturating_mul(MIB))
188}
189
190pub fn app_state_dir(app_data_dir: &Path) -> PathBuf {
191 app_data_dir.join(APP_STATE_DIR)
192}
193
194pub fn app_state_file(app_data_dir: &Path, name: &str) -> PathBuf {
195 app_state_dir(app_data_dir).join(name)
196}
197
198fn validate_panels(panels: Option<&PanelsConfig>) -> Result<(), AppContextError> {
199 let Some(panels) = panels else {
200 return Ok(());
201 };
202
203 let mut ids = HashSet::new();
204 let mut positions = HashSet::new();
205 let mut app_ids = HashSet::new();
206
207 for item in &panels.items {
208 if item.id.is_empty() {
209 return Err(AppContextError::InvalidConfig(
210 "panels.items[].id cannot be empty".to_string(),
211 ));
212 }
213 if item.label.is_empty() {
214 return Err(AppContextError::InvalidConfig(format!(
215 "panel '{}' label cannot be empty",
216 item.id
217 )));
218 }
219 if item.content.app_id.is_empty() {
220 return Err(AppContextError::InvalidConfig(format!(
221 "panel '{}' content.appId cannot be empty",
222 item.id
223 )));
224 }
225 if !ids.insert(item.id.clone()) {
226 return Err(AppContextError::InvalidConfig(format!(
227 "duplicate panel id '{}'",
228 item.id
229 )));
230 }
231 if !positions.insert(item.position) {
232 return Err(AppContextError::InvalidConfig(format!(
233 "only one panel is supported at position '{}'",
234 panel_position_name(item.position)
235 )));
236 }
237 if !app_ids.insert(item.content.app_id.clone()) {
238 return Err(AppContextError::InvalidConfig(format!(
239 "panel appId '{}' must be unique",
240 item.content.app_id
241 )));
242 }
243 }
244
245 Ok(())
246}
247
248fn panel_position_name(position: PanelPosition) -> &'static str {
249 match position {
250 PanelPosition::Left => "left",
251 PanelPosition::Right => "right",
252 PanelPosition::Bottom => "bottom",
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::{AppConfig, AppContextError, set_app_config};
259
260 fn test_config(product_name: &str) -> AppConfig {
261 AppConfig {
262 product_name: product_name.to_string(),
263 product_version: "1.0.0".to_string(),
264 lingxia_id: Some("lingxia".to_string()),
265 api_server: None,
266 home_lxapp_appid: "home".to_string(),
267 home_lxapp_version: "1.0.0".to_string(),
268 cache_max_age_days: 7,
269 cache_max_size_mb: 1024,
270 panels: None,
271 }
272 }
273
274 #[test]
275 fn set_app_config_rejects_mismatched_value_after_initialization() {
276 let cfg = test_config("LingXia");
277 assert!(set_app_config(cfg.clone()).is_ok());
278 assert!(set_app_config(cfg).is_ok());
279 let err = set_app_config(test_config("Other")).unwrap_err();
280 assert!(matches!(err, AppContextError::InvalidConfig(_)));
281 }
282}