1use semver::Version;
2use serde::de::Error as _;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::path::{Path, PathBuf};
6use std::sync::OnceLock;
7use thiserror::Error;
8
9static APP_CONFIG: OnceLock<AppConfig> = OnceLock::new();
10const APP_STATE_DIR: &str = "app_state";
11
12#[derive(Debug, Error)]
13pub enum AppContextError {
14 #[error("invalid app.json: {0}")]
15 InvalidJson(String),
16 #[error("invalid app config: {0}")]
17 InvalidConfig(String),
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Default)]
27#[serde(rename_all = "lowercase")]
28pub enum EnvVersion {
29 #[default]
30 Release,
31 Preview,
32 Developer,
33}
34
35impl EnvVersion {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Release => "release",
39 Self::Preview => "preview",
40 Self::Developer => "developer",
41 }
42 }
43}
44
45impl std::fmt::Display for EnvVersion {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.write_str(self.as_str())
48 }
49}
50
51#[derive(Clone, Copy, PartialEq, Eq, Hash)]
53pub struct ThemeColor(u32);
54
55impl ThemeColor {
56 pub fn parse(value: &str) -> Result<Self, String> {
57 if value.len() != 7 || !value.starts_with('#') {
58 return Err("theme colors must use opaque #RRGGBB syntax".to_string());
59 }
60 let rgb = u32::from_str_radix(&value[1..], 16)
61 .map_err(|_| "theme colors must use opaque #RRGGBB syntax".to_string())?;
62 Ok(Self(rgb))
63 }
64
65 pub const fn rgb(self) -> u32 {
66 self.0
67 }
68}
69
70impl std::fmt::Debug for ThemeColor {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 write!(f, "ThemeColor(#{:06X})", self.0)
73 }
74}
75
76impl std::fmt::Display for ThemeColor {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 write!(f, "#{:06X}", self.0)
79 }
80}
81
82impl Serialize for ThemeColor {
83 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
84 where
85 S: serde::Serializer,
86 {
87 serializer.serialize_str(&self.to_string())
88 }
89}
90
91impl<'de> Deserialize<'de> for ThemeColor {
92 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
93 where
94 D: serde::Deserializer<'de>,
95 {
96 let value = String::deserialize(deserializer)?;
97 Self::parse(&value).map_err(D::Error::custom)
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct ThemeStyle {
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub window_background_color: Option<ThemeColor>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub surface_background_color: Option<ThemeColor>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub foreground_color: Option<ThemeColor>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub muted_foreground_color: Option<ThemeColor>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub accent_color: Option<ThemeColor>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub separator_color: Option<ThemeColor>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub selection_background_color: Option<ThemeColor>,
118}
119
120impl ThemeStyle {
121 pub fn is_empty(&self) -> bool {
122 *self == Self::default()
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
127#[serde(deny_unknown_fields)]
128pub struct ThemeConfig {
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub light: Option<ThemeStyle>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub dark: Option<ThemeStyle>,
133}
134
135impl ThemeConfig {
136 pub fn normalized(mut self) -> Option<Self> {
137 self.light = self.light.filter(|style| !style.is_empty());
138 self.dark = self.dark.filter(|style| !style.is_empty());
139 (self.light.is_some() || self.dark.is_some()).then_some(self)
140 }
141
142 pub fn style(&self, dark: bool) -> Option<&ThemeStyle> {
143 if dark {
144 self.dark.as_ref()
145 } else {
146 self.light.as_ref()
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
152pub struct AppConfig {
153 #[serde(rename = "productName")]
154 pub product_name: String,
155 #[serde(rename = "productVersion")]
156 pub product_version: String,
157
158 #[serde(rename = "lingxiaId", default)]
159 pub lingxia_id: Option<String>,
160
161 #[serde(rename = "lingxiaServer", default)]
162 pub lingxia_server: Option<String>,
163
164 #[serde(rename = "envVersion", default)]
167 pub env_version: EnvVersion,
168
169 #[serde(
170 rename = "homeAppId",
171 default,
172 skip_serializing_if = "String::is_empty"
173 )]
174 pub home_app_id: String,
175
176 #[serde(
177 rename = "homeAppVersion",
178 default,
179 skip_serializing_if = "String::is_empty"
180 )]
181 pub home_app_version: String,
182
183 #[serde(rename = "cacheMaxSizeMB", default = "default_cache_max_size_mb")]
184 pub cache_max_size_mb: u64,
185
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub storage: Option<StorageConfig>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub splash: Option<SplashConfig>,
191
192 #[serde(rename = "devWsUrl", default, skip_serializing_if = "Option::is_none")]
193 pub dev_ws_url: Option<String>,
194
195 #[serde(
196 rename = "devBundleBaseUrl",
197 default,
198 skip_serializing_if = "Option::is_none"
199 )]
200 pub dev_bundle_base_url: Option<String>,
201
202 #[serde(rename = "appLinks", default, skip_serializing_if = "Option::is_none")]
203 pub app_links: Option<AppLinksConfig>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub theme: Option<ThemeConfig>,
207
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub capabilities: Option<CapabilitiesConfig>,
210
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub panels: Option<PanelsConfig>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
222#[serde(rename_all = "camelCase", deny_unknown_fields)]
223pub struct CapabilitiesConfig {
224 #[serde(default)]
225 pub notifications: bool,
226 #[serde(default)]
229 pub browser: bool,
230 #[serde(default)]
231 pub terminal: bool,
232 #[serde(default)]
234 pub proxy: bool,
235 #[serde(default)]
238 pub process: bool,
239 #[serde(default)]
242 pub autostart: bool,
243 #[serde(default)]
249 pub app_use: bool,
250 #[serde(default)]
255 pub computer_use: bool,
256 #[serde(default)]
258 pub browser_use: bool,
259 #[serde(default, skip_serializing_if = "MediaCaptureConfig::is_empty")]
263 pub media_capture: MediaCaptureConfig,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
268#[serde(rename_all = "camelCase", deny_unknown_fields)]
269pub struct MediaCaptureConfig {
270 #[serde(default)]
271 pub visual: bool,
272 #[serde(default)]
273 pub system_audio: bool,
274 #[serde(default)]
275 pub microphone: bool,
276}
277
278impl MediaCaptureConfig {
279 pub fn is_enabled(&self) -> bool {
280 self.visual || self.system_audio || self.microphone
281 }
282
283 pub fn is_empty(&self) -> bool {
284 !self.is_enabled()
285 }
286}
287
288impl CapabilitiesConfig {
289 pub fn needs_control_socket(&self) -> bool {
293 self.app_use_effective() || self.browser_use
294 }
295
296 pub fn app_use_effective(&self) -> bool {
308 self.app_use || self.computer_use
309 }
310
311 pub fn media_capture_enabled(&self) -> bool {
312 self.media_capture.is_enabled()
313 }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
317pub struct AppLinksConfig {
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub hosts: Vec<String>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
326#[serde(rename_all = "camelCase")]
327pub struct SplashConfig {
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub min_duration: Option<u32>,
330}
331
332pub const DEFAULT_SPLASH_MIN_DURATION_MS: u32 = 600;
335
336#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
337#[serde(rename_all = "camelCase")]
338pub struct StorageConfig {
339 #[serde(rename = "tempMaxSizeMB")]
340 #[serde(default = "default_temp_max_size_mb")]
341 pub temp_max_size_mb: u64,
342 #[serde(rename = "cacheMaxSizeMB")]
343 #[serde(default = "default_cache_max_size_mb")]
344 pub cache_max_size_mb: u64,
345 #[serde(rename = "dataMaxSizeMB")]
346 #[serde(default = "default_data_max_size_mb")]
347 pub data_max_size_mb: u64,
348 #[serde(rename = "appStorageMaxSizeMB")]
349 #[serde(default = "default_app_storage_max_size_mb")]
350 pub app_storage_max_size_mb: u64,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
354pub struct PanelsConfig {
355 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub items: Vec<PanelItem>,
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
360#[serde(rename_all = "lowercase")]
361pub enum PanelPosition {
362 Left,
363 Right,
364 Top,
365 Bottom,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
369pub struct PanelItem {
370 pub id: String,
371 pub label: String,
372 pub icon: String,
373 #[serde(default = "default_panel_position")]
374 pub position: PanelPosition,
375 pub content: PanelContent,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Default)]
379#[serde(rename_all = "lowercase")]
380pub enum PanelContentKind {
381 #[default]
382 LxApp,
383 Terminal,
384}
385
386impl PanelContentKind {
387 pub fn is_lxapp(self) -> bool {
388 self == PanelContentKind::LxApp
389 }
390}
391
392fn is_lxapp_panel_content_kind(kind: &PanelContentKind) -> bool {
393 kind.is_lxapp()
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
397pub struct PanelContent {
398 #[serde(default, skip_serializing_if = "is_lxapp_panel_content_kind")]
399 pub kind: PanelContentKind,
400 #[serde(rename = "appId")]
401 #[serde(default, skip_serializing_if = "String::is_empty")]
402 pub app_id: String,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub path: Option<String>,
405}
406
407fn default_cache_max_size_mb() -> u64 {
408 2048
409}
410
411fn default_temp_max_size_mb() -> u64 {
412 1024
413}
414
415fn default_data_max_size_mb() -> u64 {
416 4096
417}
418
419fn default_app_storage_max_size_mb() -> u64 {
420 16384
421}
422
423fn default_panel_position() -> PanelPosition {
424 PanelPosition::Right
425}
426
427impl AppConfig {
428 pub fn parse_and_validate(content: &str) -> Result<Self, AppContextError> {
429 let mut config: Self = serde_json::from_str(content).map_err(|e| {
430 AppContextError::InvalidJson(format!("Failed to parse app.json: {}", e))
431 })?;
432 config.theme = config.theme.take().and_then(ThemeConfig::normalized);
433 config.validate()?;
434 Ok(config)
435 }
436
437 fn validate(&self) -> Result<(), AppContextError> {
438 if self.product_name.is_empty() {
439 return Err(AppContextError::InvalidConfig(
440 "productName is mandatory and cannot be empty".to_string(),
441 ));
442 }
443 if self.product_version.is_empty() {
444 return Err(AppContextError::InvalidConfig(
445 "productVersion is mandatory and cannot be empty".to_string(),
446 ));
447 }
448 Version::parse(&self.product_version).map_err(|_| {
449 AppContextError::InvalidConfig(
450 "productVersion must be a semantic version (major.minor.patch)".to_string(),
451 )
452 })?;
453 if self.home_app_id.is_empty() != self.home_app_version.is_empty() {
454 return Err(AppContextError::InvalidConfig(
455 "homeAppId and homeAppVersion must either both be set or both be omitted"
456 .to_string(),
457 ));
458 }
459 if !self.home_app_version.is_empty() {
460 Version::parse(&self.home_app_version).map_err(|_| {
461 AppContextError::InvalidConfig(
462 "homeAppVersion must be a semantic version (major.minor.patch)".to_string(),
463 )
464 })?;
465 }
466 validate_panels(self.panels.as_ref())
467 }
468}
469
470pub fn set_app_config(config: AppConfig) -> Result<(), AppContextError> {
471 if let Some(existing) = APP_CONFIG.get() {
472 if existing == &config {
473 return Ok(());
474 }
475 return Err(AppContextError::InvalidConfig(
476 "app config is already initialized with different values".to_string(),
477 ));
478 }
479
480 APP_CONFIG
481 .set(config)
482 .map_err(|_| {
483 AppContextError::InvalidConfig(
484 "app config was initialized concurrently with different values".to_string(),
485 )
486 })
487 .map(|_| ())
488}
489
490pub fn app_config() -> Option<&'static AppConfig> {
491 APP_CONFIG.get()
492}
493
494pub fn theme() -> Option<&'static ThemeConfig> {
495 APP_CONFIG.get().and_then(|config| config.theme.as_ref())
496}
497
498static STARTUP: std::sync::LazyLock<std::time::Instant> =
501 std::sync::LazyLock::new(std::time::Instant::now);
502
503pub fn mark_startup() {
505 let _ = *STARTUP;
506}
507
508pub fn since_startup() -> std::time::Duration {
509 STARTUP.elapsed()
510}
511
512static SPLASH_MIN_DURATION_OVERRIDE: std::sync::atomic::AtomicU32 =
515 std::sync::atomic::AtomicU32::new(u32::MAX);
516const SPLASH_HOLD_CAP_MS: u32 = 6_000;
517
518pub fn set_splash_min_duration_override(ms: u32) {
521 SPLASH_MIN_DURATION_OVERRIDE.store(
522 ms.min(SPLASH_HOLD_CAP_MS),
523 std::sync::atomic::Ordering::Relaxed,
524 );
525}
526
527pub fn splash_min_duration() -> std::time::Duration {
529 let overridden = SPLASH_MIN_DURATION_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed);
530 if overridden != u32::MAX {
531 return std::time::Duration::from_millis(u64::from(overridden));
532 }
533 let ms = APP_CONFIG
534 .get()
535 .and_then(|config| config.splash.as_ref())
536 .and_then(|splash| splash.min_duration)
537 .unwrap_or(DEFAULT_SPLASH_MIN_DURATION_MS);
538 std::time::Duration::from_millis(u64::from(ms))
539}
540
541pub fn product_name() -> Option<&'static str> {
542 APP_CONFIG.get().map(|c| c.product_name.as_str())
543}
544
545pub fn home_app_id() -> Option<&'static str> {
546 APP_CONFIG
547 .get()
548 .map(|c| c.home_app_id.as_str())
549 .filter(|value| !value.is_empty())
550}
551
552pub fn home_app_version() -> Option<&'static str> {
553 APP_CONFIG
554 .get()
555 .map(|c| c.home_app_version.as_str())
556 .filter(|value| !value.is_empty())
557}
558
559pub fn product_version() -> Option<&'static str> {
560 APP_CONFIG.get().map(|c| c.product_version.as_str())
561}
562
563pub fn lingxia_id() -> Option<&'static str> {
564 APP_CONFIG
565 .get()
566 .and_then(|c| c.lingxia_id.as_deref())
567 .filter(|s| !s.is_empty())
568}
569
570pub fn env_version() -> EnvVersion {
574 APP_CONFIG.get().map(|c| c.env_version).unwrap_or_default()
575}
576
577pub fn notifications_enabled() -> bool {
578 APP_CONFIG
579 .get()
580 .and_then(|c| c.capabilities.as_ref())
581 .map(|capabilities| capabilities.notifications)
582 .unwrap_or(false)
583}
584
585pub fn browser_enabled() -> bool {
586 APP_CONFIG
587 .get()
588 .and_then(|config| config.capabilities.as_ref())
589 .map(|capabilities| capabilities.browser)
590 .unwrap_or(false)
591}
592
593fn capabilities_config() -> Option<&'static CapabilitiesConfig> {
595 APP_CONFIG
596 .get()
597 .and_then(|config| config.capabilities.as_ref())
598}
599
600pub fn proxy_enabled() -> bool {
601 APP_CONFIG
602 .get()
603 .and_then(|config| config.capabilities.as_ref())
604 .map(|capabilities| capabilities.proxy)
605 .unwrap_or(false)
606}
607
608pub fn autostart_enabled() -> bool {
609 APP_CONFIG
610 .get()
611 .and_then(|c| c.capabilities.as_ref())
612 .map(|capabilities| capabilities.autostart)
613 .unwrap_or(false)
614}
615
616pub fn terminal_enabled() -> bool {
617 APP_CONFIG
618 .get()
619 .and_then(|c| c.capabilities.as_ref())
620 .map(|capabilities| capabilities.terminal)
621 .unwrap_or(false)
622}
623
624#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
631pub struct HostBuild {
632 pub browser: bool,
633 pub terminal: bool,
634 pub proxy: bool,
635}
636
637static HOST_BUILD: OnceLock<HostBuild> = OnceLock::new();
638
639pub fn set_host_build(build: HostBuild) {
641 let _ = HOST_BUILD.set(build);
642}
643
644pub fn host_build() -> HostBuild {
645 HOST_BUILD.get().copied().unwrap_or_default()
646}
647
648pub fn host_build_recorded() -> bool {
651 HOST_BUILD.get().is_some()
652}
653
654pub mod capability {
658 pub mod build {
661 pub fn browser() -> bool {
662 super::super::host_build().browser
663 }
664
665 pub fn terminal() -> bool {
666 super::super::host_build().terminal
667 }
668
669 pub fn proxy() -> bool {
670 super::super::host_build().proxy
671 }
672
673 pub fn notifications() -> bool {
675 cfg!(any(target_os = "ios", target_env = "ohos"))
676 }
677 }
678
679 pub fn browser() -> bool {
681 build::browser() && super::browser_enabled()
682 }
683
684 pub fn notifications() -> bool {
686 build::notifications() && super::notifications_enabled()
687 }
688
689 pub fn app_use() -> bool {
693 super::capabilities_config()
694 .map(|capabilities| capabilities.app_use_effective())
695 .unwrap_or(false)
696 }
697
698 pub fn computer_use() -> bool {
700 super::capabilities_config()
701 .map(|capabilities| capabilities.computer_use)
702 .unwrap_or(false)
703 }
704
705 pub fn browser_use() -> bool {
709 browser()
710 && super::capabilities_config()
711 .map(|capabilities| capabilities.browser_use)
712 .unwrap_or(false)
713 }
714
715 pub fn media_capture() -> bool {
717 super::capabilities_config()
718 .map(|capabilities| capabilities.media_capture_enabled())
719 .unwrap_or(false)
720 }
721
722 pub fn proxy() -> bool {
726 build::proxy() && super::proxy_enabled() && super::browser_enabled()
727 }
728}
729
730pub fn process_enabled() -> bool {
731 APP_CONFIG
732 .get()
733 .and_then(|c| c.capabilities.as_ref())
734 .map(|capabilities| capabilities.process)
735 .unwrap_or(false)
736}
737
738pub fn temp_max_size_bytes() -> u64 {
739 const MIB: u64 = 1024 * 1024;
740 APP_CONFIG
741 .get()
742 .and_then(|c| c.storage.as_ref().map(|storage| storage.temp_max_size_mb))
743 .unwrap_or_else(default_temp_max_size_mb)
744 .saturating_mul(MIB)
745}
746
747pub fn cache_max_size_bytes() -> u64 {
748 const MIB: u64 = 1024 * 1024;
749 APP_CONFIG
750 .get()
751 .map(|c| {
752 c.storage
753 .as_ref()
754 .map(|storage| storage.cache_max_size_mb)
755 .unwrap_or(c.cache_max_size_mb)
756 })
757 .unwrap_or_else(default_cache_max_size_mb)
758 .saturating_mul(MIB)
759}
760
761pub fn data_max_size_bytes() -> u64 {
762 const MIB: u64 = 1024 * 1024;
763 APP_CONFIG
764 .get()
765 .and_then(|c| c.storage.as_ref().map(|storage| storage.data_max_size_mb))
766 .unwrap_or_else(default_data_max_size_mb)
767 .saturating_mul(MIB)
768}
769
770pub fn app_storage_max_size_bytes() -> u64 {
771 const MIB: u64 = 1024 * 1024;
772 APP_CONFIG
773 .get()
774 .and_then(|c| {
775 c.storage
776 .as_ref()
777 .map(|storage| storage.app_storage_max_size_mb)
778 })
779 .unwrap_or_else(default_app_storage_max_size_mb)
780 .saturating_mul(MIB)
781}
782
783pub fn app_state_dir(app_data_dir: &Path) -> PathBuf {
784 app_data_dir.join(APP_STATE_DIR)
785}
786
787pub fn app_state_file(app_data_dir: &Path, name: &str) -> PathBuf {
788 app_state_dir(app_data_dir).join(name)
789}
790
791fn validate_panels(panels: Option<&PanelsConfig>) -> Result<(), AppContextError> {
792 let Some(panels) = panels else {
793 return Ok(());
794 };
795
796 let mut ids = HashSet::new();
797 let mut positions = HashSet::new();
798 let mut app_ids = HashSet::new();
799
800 for item in &panels.items {
801 if item.id.is_empty() {
802 return Err(AppContextError::InvalidConfig(
803 "panels.items[].id cannot be empty".to_string(),
804 ));
805 }
806 if item.label.is_empty() {
807 return Err(AppContextError::InvalidConfig(format!(
808 "panel '{}' label cannot be empty",
809 item.id
810 )));
811 }
812 if item.content.kind == PanelContentKind::LxApp && item.content.app_id.is_empty() {
813 return Err(AppContextError::InvalidConfig(format!(
814 "panel '{}' content.appId cannot be empty",
815 item.id
816 )));
817 }
818 if !ids.insert(item.id.clone()) {
819 return Err(AppContextError::InvalidConfig(format!(
820 "duplicate panel id '{}'",
821 item.id
822 )));
823 }
824 if !positions.insert(item.position) {
825 return Err(AppContextError::InvalidConfig(format!(
826 "only one panel is supported at position '{}'",
827 panel_position_name(item.position)
828 )));
829 }
830 if item.content.kind == PanelContentKind::LxApp
831 && !app_ids.insert(item.content.app_id.clone())
832 {
833 return Err(AppContextError::InvalidConfig(format!(
834 "panel appId '{}' must be unique",
835 item.content.app_id
836 )));
837 }
838 }
839
840 Ok(())
841}
842
843fn panel_position_name(position: PanelPosition) -> &'static str {
844 match position {
845 PanelPosition::Left => "left",
846 PanelPosition::Right => "right",
847 PanelPosition::Top => "top",
848 PanelPosition::Bottom => "bottom",
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::{AppConfig, AppContextError, ThemeColor, ThemeConfig, set_app_config};
855
856 fn test_config(product_name: &str) -> AppConfig {
857 AppConfig {
858 product_name: product_name.to_string(),
859 product_version: "1.0.0".to_string(),
860 lingxia_id: Some("lingxia".to_string()),
861 lingxia_server: None,
862 env_version: super::EnvVersion::Release,
863 home_app_id: "home".to_string(),
864 home_app_version: "1.0.0".to_string(),
865 cache_max_size_mb: 1024,
866 storage: None,
867 splash: None,
868 dev_ws_url: None,
869 dev_bundle_base_url: None,
870 app_links: None,
871 theme: None,
872 capabilities: None,
873 panels: None,
874 }
875 }
876
877 #[test]
878 fn set_app_config_rejects_mismatched_value_after_initialization() {
879 let cfg = test_config("LingXia");
880 assert!(set_app_config(cfg.clone()).is_ok());
881 assert!(set_app_config(cfg).is_ok());
882 let err = set_app_config(test_config("Other")).unwrap_err();
883 assert!(matches!(err, AppContextError::InvalidConfig(_)));
884 }
885
886 #[test]
887 fn host_without_home_lxapp_is_valid() {
888 let mut config = test_config("Web Host");
889 config.home_app_id.clear();
890 config.home_app_version.clear();
891
892 let json = serde_json::to_string(&config).unwrap();
893 assert!(!json.contains("homeAppId"));
894 assert!(!json.contains("homeAppVersion"));
895 assert!(AppConfig::parse_and_validate(&json).is_ok());
896 }
897
898 #[test]
899 fn home_lxapp_identity_must_be_complete() {
900 let mut config = test_config("Broken Host");
901 config.home_app_version.clear();
902
903 let error = config.validate().unwrap_err();
904 assert!(matches!(error, AppContextError::InvalidConfig(_)));
905 }
906
907 #[test]
908 fn theme_colors_validate_and_serialize_canonically() {
909 let config = AppConfig::parse_and_validate(
910 r##"{
911 "productName": "Theme Test",
912 "productVersion": "1.0.0",
913 "theme": {
914 "light": { "accentColor": "#a1b2c3" },
915 "dark": { "separatorColor": "#343840" }
916 }
917 }"##,
918 )
919 .expect("valid theme");
920
921 let light = config
922 .theme
923 .as_ref()
924 .and_then(|theme| theme.light.as_ref())
925 .expect("light style");
926 assert_eq!(light.accent_color.map(ThemeColor::rgb), Some(0xA1B2C3));
927
928 let json = serde_json::to_string(&config).expect("serialize app config");
929 assert!(json.contains("#A1B2C3"));
930 }
931
932 #[test]
933 fn theme_rejects_alpha_and_unknown_fields() {
934 for theme in [
935 r##"{ "light": { "accentColor": "#80A1B2C3" } }"##,
936 r##"{ "light": { "sidebarBackgroundColor": "#A1B2C3" } }"##,
937 r##"{ "highContrast": { "accentColor": "#A1B2C3" } }"##,
938 ] {
939 let json = format!(
940 r#"{{ "productName": "Theme Test", "productVersion": "1.0.0", "theme": {theme} }}"#
941 );
942 assert!(AppConfig::parse_and_validate(&json).is_err(), "{theme}");
943 }
944 }
945
946 #[test]
947 fn empty_theme_blocks_normalize_to_absence() {
948 let theme: ThemeConfig =
949 serde_json::from_str(r#"{ "light": {}, "dark": {} }"#).expect("parse empty theme");
950 assert!(theme.normalized().is_none());
951 }
952}