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")]
114 pub page_background_color: Option<ThemeColor>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub window_background_color: Option<ThemeColor>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub surface_background_color: Option<ThemeColor>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub foreground_color: Option<ThemeColor>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub muted_foreground_color: Option<ThemeColor>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub accent_color: Option<ThemeColor>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub separator_color: Option<ThemeColor>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub selection_background_color: Option<ThemeColor>,
129}
130
131impl ThemeStyle {
132 pub fn is_empty(&self) -> bool {
133 *self == Self::default()
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
138#[serde(deny_unknown_fields)]
139pub struct ThemeConfig {
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub light: Option<ThemeStyle>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub dark: Option<ThemeStyle>,
144}
145
146impl ThemeConfig {
147 pub fn normalized(mut self) -> Option<Self> {
148 self.light = self.light.filter(|style| !style.is_empty());
149 self.dark = self.dark.filter(|style| !style.is_empty());
150 (self.light.is_some() || self.dark.is_some()).then_some(self)
151 }
152
153 pub fn style(&self, dark: bool) -> Option<&ThemeStyle> {
154 if dark {
155 self.dark.as_ref()
156 } else {
157 self.light.as_ref()
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
163pub struct AppConfig {
164 #[serde(rename = "productName")]
165 pub product_name: String,
166 #[serde(rename = "productVersion")]
167 pub product_version: String,
168
169 #[serde(rename = "lingxiaId", default)]
170 pub lingxia_id: Option<String>,
171
172 #[serde(rename = "lingxiaServer", default)]
173 pub lingxia_server: Option<String>,
174
175 #[serde(rename = "envVersion", default)]
178 pub env_version: EnvVersion,
179
180 #[serde(
181 rename = "homeAppId",
182 default,
183 skip_serializing_if = "String::is_empty"
184 )]
185 pub home_app_id: String,
186
187 #[serde(
188 rename = "homeAppVersion",
189 default,
190 skip_serializing_if = "String::is_empty"
191 )]
192 pub home_app_version: String,
193
194 #[serde(rename = "cacheMaxSizeMB", default = "default_cache_max_size_mb")]
195 pub cache_max_size_mb: u64,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub storage: Option<StorageConfig>,
199
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub splash: Option<SplashConfig>,
202
203 #[serde(rename = "devWsUrl", default, skip_serializing_if = "Option::is_none")]
204 pub dev_ws_url: Option<String>,
205
206 #[serde(
207 rename = "devBundleBaseUrl",
208 default,
209 skip_serializing_if = "Option::is_none"
210 )]
211 pub dev_bundle_base_url: Option<String>,
212
213 #[serde(rename = "appLinks", default, skip_serializing_if = "Option::is_none")]
214 pub app_links: Option<AppLinksConfig>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub theme: Option<ThemeConfig>,
218
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub capabilities: Option<CapabilitiesConfig>,
221
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub panels: Option<PanelsConfig>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
233#[serde(rename_all = "camelCase", deny_unknown_fields)]
234pub struct CapabilitiesConfig {
235 #[serde(default)]
236 pub notifications: bool,
237 #[serde(default)]
240 pub browser: bool,
241 #[serde(default)]
242 pub terminal: bool,
243 #[serde(default)]
245 pub proxy: bool,
246 #[serde(default)]
249 pub process: bool,
250 #[serde(default)]
253 pub autostart: bool,
254 #[serde(default)]
260 pub app_use: bool,
261 #[serde(default)]
266 pub computer_use: bool,
267 #[serde(default)]
269 pub browser_use: bool,
270 #[serde(default, skip_serializing_if = "MediaCaptureConfig::is_empty")]
274 pub media_capture: MediaCaptureConfig,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
279#[serde(rename_all = "camelCase", deny_unknown_fields)]
280pub struct MediaCaptureConfig {
281 #[serde(default)]
282 pub visual: bool,
283 #[serde(default)]
284 pub system_audio: bool,
285 #[serde(default)]
286 pub microphone: bool,
287}
288
289impl MediaCaptureConfig {
290 pub fn is_enabled(&self) -> bool {
291 self.visual || self.system_audio || self.microphone
292 }
293
294 pub fn is_empty(&self) -> bool {
295 !self.is_enabled()
296 }
297}
298
299impl CapabilitiesConfig {
300 pub fn needs_control_socket(&self) -> bool {
304 self.app_use_effective() || self.browser_use
305 }
306
307 pub fn app_use_effective(&self) -> bool {
319 self.app_use || self.computer_use
320 }
321
322 pub fn media_capture_enabled(&self) -> bool {
323 self.media_capture.is_enabled()
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
328pub struct AppLinksConfig {
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub hosts: Vec<String>,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
337#[serde(rename_all = "camelCase")]
338pub struct SplashConfig {
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub min_duration: Option<u32>,
341}
342
343pub const DEFAULT_SPLASH_MIN_DURATION_MS: u32 = 600;
346
347#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
348#[serde(rename_all = "camelCase")]
349pub struct StorageConfig {
350 #[serde(rename = "tempMaxSizeMB")]
351 #[serde(default = "default_temp_max_size_mb")]
352 pub temp_max_size_mb: u64,
353 #[serde(rename = "cacheMaxSizeMB")]
354 #[serde(default = "default_cache_max_size_mb")]
355 pub cache_max_size_mb: u64,
356 #[serde(rename = "dataMaxSizeMB")]
357 #[serde(default = "default_data_max_size_mb")]
358 pub data_max_size_mb: u64,
359 #[serde(rename = "appStorageMaxSizeMB")]
360 #[serde(default = "default_app_storage_max_size_mb")]
361 pub app_storage_max_size_mb: u64,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
365pub struct PanelsConfig {
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub items: Vec<PanelItem>,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
371#[serde(rename_all = "lowercase")]
372pub enum PanelPosition {
373 Left,
374 Right,
375 Top,
376 Bottom,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
380pub struct PanelItem {
381 pub id: String,
382 pub label: String,
383 pub icon: String,
384 #[serde(default = "default_panel_position")]
385 pub position: PanelPosition,
386 pub content: PanelContent,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Default)]
390#[serde(rename_all = "lowercase")]
391pub enum PanelContentKind {
392 #[default]
393 LxApp,
394 Terminal,
395}
396
397impl PanelContentKind {
398 pub fn is_lxapp(self) -> bool {
399 self == PanelContentKind::LxApp
400 }
401}
402
403fn is_lxapp_panel_content_kind(kind: &PanelContentKind) -> bool {
404 kind.is_lxapp()
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
408pub struct PanelContent {
409 #[serde(default, skip_serializing_if = "is_lxapp_panel_content_kind")]
410 pub kind: PanelContentKind,
411 #[serde(rename = "appId")]
412 #[serde(default, skip_serializing_if = "String::is_empty")]
413 pub app_id: String,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub path: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub page: Option<String>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub query: Option<serde_json::Value>,
420}
421
422fn default_cache_max_size_mb() -> u64 {
423 2048
424}
425
426fn default_temp_max_size_mb() -> u64 {
427 1024
428}
429
430fn default_data_max_size_mb() -> u64 {
431 4096
432}
433
434fn default_app_storage_max_size_mb() -> u64 {
435 16384
436}
437
438fn default_panel_position() -> PanelPosition {
439 PanelPosition::Right
440}
441
442impl AppConfig {
443 pub fn parse_and_validate(content: &str) -> Result<Self, AppContextError> {
444 let mut config: Self = serde_json::from_str(content).map_err(|e| {
445 AppContextError::InvalidJson(format!("Failed to parse app.json: {}", e))
446 })?;
447 config.theme = config.theme.take().and_then(ThemeConfig::normalized);
448 config.validate()?;
449 Ok(config)
450 }
451
452 fn validate(&self) -> Result<(), AppContextError> {
453 if self.product_name.is_empty() {
454 return Err(AppContextError::InvalidConfig(
455 "productName is mandatory and cannot be empty".to_string(),
456 ));
457 }
458 if self.product_version.is_empty() {
459 return Err(AppContextError::InvalidConfig(
460 "productVersion is mandatory and cannot be empty".to_string(),
461 ));
462 }
463 Version::parse(&self.product_version).map_err(|_| {
464 AppContextError::InvalidConfig(
465 "productVersion must be a semantic version (major.minor.patch)".to_string(),
466 )
467 })?;
468 if self.home_app_id.is_empty() != self.home_app_version.is_empty() {
469 return Err(AppContextError::InvalidConfig(
470 "homeAppId and homeAppVersion must either both be set or both be omitted"
471 .to_string(),
472 ));
473 }
474 if !self.home_app_version.is_empty() {
475 Version::parse(&self.home_app_version).map_err(|_| {
476 AppContextError::InvalidConfig(
477 "homeAppVersion must be a semantic version (major.minor.patch)".to_string(),
478 )
479 })?;
480 }
481 validate_panels(self.panels.as_ref())
482 }
483}
484
485pub fn set_app_config(config: AppConfig) -> Result<(), AppContextError> {
486 if let Some(existing) = APP_CONFIG.get() {
487 if existing == &config {
488 return Ok(());
489 }
490 return Err(AppContextError::InvalidConfig(
491 "app config is already initialized with different values".to_string(),
492 ));
493 }
494
495 APP_CONFIG
496 .set(config)
497 .map_err(|_| {
498 AppContextError::InvalidConfig(
499 "app config was initialized concurrently with different values".to_string(),
500 )
501 })
502 .map(|_| ())
503}
504
505pub fn app_config() -> Option<&'static AppConfig> {
506 APP_CONFIG.get()
507}
508
509pub fn theme() -> Option<&'static ThemeConfig> {
510 APP_CONFIG.get().and_then(|config| config.theme.as_ref())
511}
512
513static STARTUP: std::sync::LazyLock<std::time::Instant> =
516 std::sync::LazyLock::new(std::time::Instant::now);
517
518pub fn mark_startup() {
520 let _ = *STARTUP;
521}
522
523pub fn since_startup() -> std::time::Duration {
524 STARTUP.elapsed()
525}
526
527static SPLASH_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
530
531pub fn mark_splash_visible() {
542 SPLASH_VISIBLE.store(true, std::sync::atomic::Ordering::Relaxed);
543}
544
545pub fn splash_visible_for() -> Option<std::time::Duration> {
558 SPLASH_VISIBLE
559 .load(std::sync::atomic::Ordering::Relaxed)
560 .then(since_startup)
561}
562
563const SPLASH_HOLD_CAP_MS: u32 = 6_000;
564
565pub fn splash_min_duration() -> std::time::Duration {
567 let ms = APP_CONFIG
568 .get()
569 .and_then(|config| config.splash.as_ref())
570 .and_then(|splash| splash.min_duration)
571 .unwrap_or(DEFAULT_SPLASH_MIN_DURATION_MS)
572 .min(SPLASH_HOLD_CAP_MS);
577 std::time::Duration::from_millis(u64::from(ms))
578}
579
580struct CampaignHandoff {
582 pending: Option<(String, u32)>,
583 closed: bool,
584}
585
586impl CampaignHandoff {
587 const fn new() -> Self {
588 Self {
589 pending: None,
590 closed: false,
591 }
592 }
593
594 fn offer(&mut self, image_path: String, duration_ms: u32) -> bool {
595 if self.closed {
596 return false;
597 }
598 self.pending = Some((image_path, duration_ms));
599 true
600 }
601
602 fn take_and_close(&mut self) -> Option<(String, u32)> {
603 self.closed = true;
604 self.pending.take()
605 }
606}
607
608static CAMPAIGN_HANDOFF: std::sync::Mutex<CampaignHandoff> =
609 std::sync::Mutex::new(CampaignHandoff::new());
610
611pub fn set_pending_campaign(image_path: String, duration_ms: u32) -> bool {
615 CAMPAIGN_HANDOFF
616 .lock()
617 .unwrap_or_else(|error| error.into_inner())
618 .offer(image_path, duration_ms)
619}
620
621pub fn take_pending_campaign() -> Option<(String, u32)> {
625 CAMPAIGN_HANDOFF
626 .lock()
627 .unwrap_or_else(|error| error.into_inner())
628 .take_and_close()
629}
630
631pub fn page_background_color(dark: bool) -> Option<String> {
636 theme()?
637 .style(dark)?
638 .page_background_color
639 .map(|color| color.to_string())
640}
641
642pub fn product_name() -> Option<&'static str> {
643 APP_CONFIG.get().map(|c| c.product_name.as_str())
644}
645
646pub fn home_app_id() -> Option<&'static str> {
647 APP_CONFIG
648 .get()
649 .map(|c| c.home_app_id.as_str())
650 .filter(|value| !value.is_empty())
651}
652
653pub fn home_app_version() -> Option<&'static str> {
654 APP_CONFIG
655 .get()
656 .map(|c| c.home_app_version.as_str())
657 .filter(|value| !value.is_empty())
658}
659
660pub fn product_version() -> Option<&'static str> {
661 APP_CONFIG.get().map(|c| c.product_version.as_str())
662}
663
664pub fn lingxia_id() -> Option<&'static str> {
665 APP_CONFIG
666 .get()
667 .and_then(|c| c.lingxia_id.as_deref())
668 .filter(|s| !s.is_empty())
669}
670
671pub fn env_version() -> EnvVersion {
675 APP_CONFIG.get().map(|c| c.env_version).unwrap_or_default()
676}
677
678pub fn notifications_enabled() -> bool {
679 APP_CONFIG
680 .get()
681 .and_then(|c| c.capabilities.as_ref())
682 .map(|capabilities| capabilities.notifications)
683 .unwrap_or(false)
684}
685
686pub fn browser_enabled() -> bool {
687 APP_CONFIG
688 .get()
689 .and_then(|config| config.capabilities.as_ref())
690 .map(|capabilities| capabilities.browser)
691 .unwrap_or(false)
692}
693
694fn capabilities_config() -> Option<&'static CapabilitiesConfig> {
696 APP_CONFIG
697 .get()
698 .and_then(|config| config.capabilities.as_ref())
699}
700
701pub fn proxy_enabled() -> bool {
702 APP_CONFIG
703 .get()
704 .and_then(|config| config.capabilities.as_ref())
705 .map(|capabilities| capabilities.proxy)
706 .unwrap_or(false)
707}
708
709pub fn autostart_enabled() -> bool {
710 APP_CONFIG
711 .get()
712 .and_then(|c| c.capabilities.as_ref())
713 .map(|capabilities| capabilities.autostart)
714 .unwrap_or(false)
715}
716
717pub fn terminal_enabled() -> bool {
718 APP_CONFIG
719 .get()
720 .and_then(|c| c.capabilities.as_ref())
721 .map(|capabilities| capabilities.terminal)
722 .unwrap_or(false)
723}
724
725#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
732pub struct HostBuild {
733 pub browser: bool,
734 pub terminal: bool,
735 pub proxy: bool,
736}
737
738static HOST_BUILD: OnceLock<HostBuild> = OnceLock::new();
739
740pub fn set_host_build(build: HostBuild) {
742 let _ = HOST_BUILD.set(build);
743}
744
745pub fn host_build() -> HostBuild {
746 HOST_BUILD.get().copied().unwrap_or_default()
747}
748
749pub fn host_build_recorded() -> bool {
752 HOST_BUILD.get().is_some()
753}
754
755pub mod capability {
759 pub mod build {
762 pub fn browser() -> bool {
763 super::super::host_build().browser
764 }
765
766 pub fn terminal() -> bool {
767 super::super::host_build().terminal
768 }
769
770 pub fn proxy() -> bool {
771 super::super::host_build().proxy
772 }
773
774 pub fn notifications() -> bool {
776 cfg!(any(target_os = "ios", target_env = "ohos"))
777 }
778 }
779
780 pub fn browser() -> bool {
782 build::browser() && super::browser_enabled()
783 }
784
785 pub fn notifications() -> bool {
787 build::notifications() && super::notifications_enabled()
788 }
789
790 pub fn app_use() -> bool {
794 super::capabilities_config()
795 .map(|capabilities| capabilities.app_use_effective())
796 .unwrap_or(false)
797 }
798
799 pub fn computer_use() -> bool {
801 super::capabilities_config()
802 .map(|capabilities| capabilities.computer_use)
803 .unwrap_or(false)
804 }
805
806 pub fn browser_use() -> bool {
810 browser()
811 && super::capabilities_config()
812 .map(|capabilities| capabilities.browser_use)
813 .unwrap_or(false)
814 }
815
816 pub fn media_capture() -> bool {
818 super::capabilities_config()
819 .map(|capabilities| capabilities.media_capture_enabled())
820 .unwrap_or(false)
821 }
822
823 pub fn proxy() -> bool {
827 build::proxy() && super::proxy_enabled() && super::browser_enabled()
828 }
829}
830
831pub fn process_enabled() -> bool {
832 APP_CONFIG
833 .get()
834 .and_then(|c| c.capabilities.as_ref())
835 .map(|capabilities| capabilities.process)
836 .unwrap_or(false)
837}
838
839pub fn temp_max_size_bytes() -> u64 {
840 const MIB: u64 = 1024 * 1024;
841 APP_CONFIG
842 .get()
843 .and_then(|c| c.storage.as_ref().map(|storage| storage.temp_max_size_mb))
844 .unwrap_or_else(default_temp_max_size_mb)
845 .saturating_mul(MIB)
846}
847
848pub fn cache_max_size_bytes() -> u64 {
849 const MIB: u64 = 1024 * 1024;
850 APP_CONFIG
851 .get()
852 .map(|c| {
853 c.storage
854 .as_ref()
855 .map(|storage| storage.cache_max_size_mb)
856 .unwrap_or(c.cache_max_size_mb)
857 })
858 .unwrap_or_else(default_cache_max_size_mb)
859 .saturating_mul(MIB)
860}
861
862pub fn data_max_size_bytes() -> u64 {
863 const MIB: u64 = 1024 * 1024;
864 APP_CONFIG
865 .get()
866 .and_then(|c| c.storage.as_ref().map(|storage| storage.data_max_size_mb))
867 .unwrap_or_else(default_data_max_size_mb)
868 .saturating_mul(MIB)
869}
870
871pub fn app_storage_max_size_bytes() -> u64 {
872 const MIB: u64 = 1024 * 1024;
873 APP_CONFIG
874 .get()
875 .and_then(|c| {
876 c.storage
877 .as_ref()
878 .map(|storage| storage.app_storage_max_size_mb)
879 })
880 .unwrap_or_else(default_app_storage_max_size_mb)
881 .saturating_mul(MIB)
882}
883
884pub fn app_state_dir(app_data_dir: &Path) -> PathBuf {
885 app_data_dir.join(APP_STATE_DIR)
886}
887
888pub fn app_state_file(app_data_dir: &Path, name: &str) -> PathBuf {
889 app_state_dir(app_data_dir).join(name)
890}
891
892fn validate_panels(panels: Option<&PanelsConfig>) -> Result<(), AppContextError> {
893 let Some(panels) = panels else {
894 return Ok(());
895 };
896
897 let mut ids = HashSet::new();
898 let mut positions = HashSet::new();
899 let mut app_ids = HashSet::new();
900
901 for item in &panels.items {
902 if item.id.is_empty() {
903 return Err(AppContextError::InvalidConfig(
904 "panels.items[].id cannot be empty".to_string(),
905 ));
906 }
907 if item.label.is_empty() {
908 return Err(AppContextError::InvalidConfig(format!(
909 "panel '{}' label cannot be empty",
910 item.id
911 )));
912 }
913 if item.content.kind == PanelContentKind::LxApp && item.content.app_id.is_empty() {
914 return Err(AppContextError::InvalidConfig(format!(
915 "panel '{}' content.appId cannot be empty",
916 item.id
917 )));
918 }
919 if !ids.insert(item.id.clone()) {
920 return Err(AppContextError::InvalidConfig(format!(
921 "duplicate panel id '{}'",
922 item.id
923 )));
924 }
925 if !positions.insert(item.position) {
926 return Err(AppContextError::InvalidConfig(format!(
927 "only one panel is supported at position '{}'",
928 panel_position_name(item.position)
929 )));
930 }
931 if item.content.kind == PanelContentKind::LxApp
932 && !app_ids.insert(item.content.app_id.clone())
933 {
934 return Err(AppContextError::InvalidConfig(format!(
935 "panel appId '{}' must be unique",
936 item.content.app_id
937 )));
938 }
939 }
940
941 Ok(())
942}
943
944fn panel_position_name(position: PanelPosition) -> &'static str {
945 match position {
946 PanelPosition::Left => "left",
947 PanelPosition::Right => "right",
948 PanelPosition::Top => "top",
949 PanelPosition::Bottom => "bottom",
950 }
951}
952
953#[cfg(test)]
954mod tests {
955 use super::{
956 AppConfig, AppContextError, CampaignHandoff, ThemeColor, ThemeConfig, set_app_config,
957 };
958
959 fn test_config(product_name: &str) -> AppConfig {
960 AppConfig {
961 product_name: product_name.to_string(),
962 product_version: "1.0.0".to_string(),
963 lingxia_id: Some("lingxia".to_string()),
964 lingxia_server: None,
965 env_version: super::EnvVersion::Release,
966 home_app_id: "home".to_string(),
967 home_app_version: "1.0.0".to_string(),
968 cache_max_size_mb: 1024,
969 storage: None,
970 splash: None,
971 dev_ws_url: None,
972 dev_bundle_base_url: None,
973 app_links: None,
974 theme: None,
975 capabilities: None,
976 panels: None,
977 }
978 }
979
980 #[test]
981 fn set_app_config_rejects_mismatched_value_after_initialization() {
982 let cfg = test_config("LingXia");
983 assert!(set_app_config(cfg.clone()).is_ok());
984 assert!(set_app_config(cfg).is_ok());
985 let err = set_app_config(test_config("Other")).unwrap_err();
986 assert!(matches!(err, AppContextError::InvalidConfig(_)));
987 }
988
989 #[test]
990 fn host_without_home_lxapp_is_valid() {
991 let mut config = test_config("Web Host");
992 config.home_app_id.clear();
993 config.home_app_version.clear();
994
995 let json = serde_json::to_string(&config).unwrap();
996 assert!(!json.contains("homeAppId"));
997 assert!(!json.contains("homeAppVersion"));
998 assert!(AppConfig::parse_and_validate(&json).is_ok());
999 }
1000
1001 #[test]
1002 fn home_lxapp_identity_must_be_complete() {
1003 let mut config = test_config("Broken Host");
1004 config.home_app_version.clear();
1005
1006 let error = config.validate().unwrap_err();
1007 assert!(matches!(error, AppContextError::InvalidConfig(_)));
1008 }
1009
1010 #[test]
1011 fn theme_colors_validate_and_serialize_canonically() {
1012 let config = AppConfig::parse_and_validate(
1013 r##"{
1014 "productName": "Theme Test",
1015 "productVersion": "1.0.0",
1016 "theme": {
1017 "light": { "accentColor": "#a1b2c3" },
1018 "dark": { "separatorColor": "#343840" }
1019 }
1020 }"##,
1021 )
1022 .expect("valid theme");
1023
1024 let light = config
1025 .theme
1026 .as_ref()
1027 .and_then(|theme| theme.light.as_ref())
1028 .expect("light style");
1029 assert_eq!(light.accent_color.map(ThemeColor::rgb), Some(0xA1B2C3));
1030
1031 let json = serde_json::to_string(&config).expect("serialize app config");
1032 assert!(json.contains("#A1B2C3"));
1033 }
1034
1035 #[test]
1036 fn theme_rejects_alpha_and_unknown_fields() {
1037 for theme in [
1038 r##"{ "light": { "accentColor": "#80A1B2C3" } }"##,
1039 r##"{ "light": { "sidebarBackgroundColor": "#A1B2C3" } }"##,
1040 r##"{ "highContrast": { "accentColor": "#A1B2C3" } }"##,
1041 ] {
1042 let json = format!(
1043 r#"{{ "productName": "Theme Test", "productVersion": "1.0.0", "theme": {theme} }}"#
1044 );
1045 assert!(AppConfig::parse_and_validate(&json).is_err(), "{theme}");
1046 }
1047 }
1048
1049 #[test]
1050 fn empty_theme_blocks_normalize_to_absence() {
1051 let theme: ThemeConfig =
1052 serde_json::from_str(r#"{ "light": {}, "dark": {} }"#).expect("parse empty theme");
1053 assert!(theme.normalized().is_none());
1054 }
1055
1056 #[test]
1057 fn campaign_handoff_drops_an_answer_after_home_is_ready() {
1058 let mut handoff = CampaignHandoff::new();
1059 assert!(handoff.offer("first.png".to_string(), 1_500));
1060 assert_eq!(
1061 handoff.take_and_close(),
1062 Some(("first.png".to_string(), 1_500))
1063 );
1064 assert!(!handoff.offer("late.png".to_string(), 3_000));
1065 assert_eq!(handoff.take_and_close(), None);
1066 }
1067}