1use semver::Version;
2use serde::de::Error as _;
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, 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)]
31#[serde(rename_all = "lowercase")]
32pub enum AppEnv {
33 Dev,
34 #[default]
35 Prod,
36}
37
38impl AppEnv {
39 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::Dev => "dev",
42 Self::Prod => "prod",
43 }
44 }
45
46 pub fn default_channel(self) -> &'static str {
50 match self {
51 Self::Dev => "draft",
52 Self::Prod => "release",
53 }
54 }
55}
56
57impl std::fmt::Display for AppEnv {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.write_str(self.as_str())
60 }
61}
62
63#[derive(Clone, Copy, PartialEq, Eq, Hash)]
65pub struct ThemeColor(u32);
66
67impl ThemeColor {
68 pub fn parse(value: &str) -> Result<Self, String> {
69 if value.len() != 7 || !value.starts_with('#') {
70 return Err("theme colors must use opaque #RRGGBB syntax".to_string());
71 }
72 let rgb = u32::from_str_radix(&value[1..], 16)
73 .map_err(|_| "theme colors must use opaque #RRGGBB syntax".to_string())?;
74 Ok(Self(rgb))
75 }
76
77 pub const fn rgb(self) -> u32 {
78 self.0
79 }
80}
81
82impl std::fmt::Debug for ThemeColor {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 write!(f, "ThemeColor(#{:06X})", self.0)
85 }
86}
87
88impl std::fmt::Display for ThemeColor {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 write!(f, "#{:06X}", self.0)
91 }
92}
93
94impl Serialize for ThemeColor {
95 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
96 where
97 S: serde::Serializer,
98 {
99 serializer.serialize_str(&self.to_string())
100 }
101}
102
103impl<'de> Deserialize<'de> for ThemeColor {
104 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
105 where
106 D: serde::Deserializer<'de>,
107 {
108 let value = String::deserialize(deserializer)?;
109 Self::parse(&value).map_err(D::Error::custom)
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
114#[serde(rename_all = "camelCase", deny_unknown_fields)]
115pub struct ThemeStyle {
116 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub page_background_color: Option<ThemeColor>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub window_background_color: Option<ThemeColor>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub surface_background_color: Option<ThemeColor>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub foreground_color: Option<ThemeColor>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub muted_foreground_color: Option<ThemeColor>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub accent_color: Option<ThemeColor>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub separator_color: Option<ThemeColor>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub selection_background_color: Option<ThemeColor>,
141}
142
143impl ThemeStyle {
144 pub fn is_empty(&self) -> bool {
145 *self == Self::default()
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
153#[serde(rename_all = "lowercase")]
154pub enum AppearancePreference {
155 #[default]
156 Auto,
157 Light,
158 Dark,
159}
160
161impl AppearancePreference {
162 pub const fn as_str(self) -> &'static str {
163 match self {
164 Self::Auto => "auto",
165 Self::Light => "light",
166 Self::Dark => "dark",
167 }
168 }
169}
170
171impl std::str::FromStr for AppearancePreference {
172 type Err = String;
173
174 fn from_str(value: &str) -> Result<Self, Self::Err> {
175 match value {
176 "auto" => Ok(Self::Auto),
177 "light" => Ok(Self::Light),
178 "dark" => Ok(Self::Dark),
179 other => Err(format!(
180 "appearance: expected auto, light, or dark; received '{other}'"
181 )),
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
187#[serde(deny_unknown_fields)]
188pub struct ThemeConfig {
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub light: Option<ThemeStyle>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub dark: Option<ThemeStyle>,
193 #[serde(
197 rename = "defaultAppearance",
198 default,
199 skip_serializing_if = "Option::is_none"
200 )]
201 pub default_appearance: Option<AppearancePreference>,
202}
203
204impl ThemeConfig {
205 pub fn normalized(mut self) -> Option<Self> {
206 self.light = self.light.filter(|style| !style.is_empty());
207 self.dark = self.dark.filter(|style| !style.is_empty());
208 self.default_appearance = self
210 .default_appearance
211 .filter(|appearance| *appearance != AppearancePreference::Auto);
212 (self.light.is_some() || self.dark.is_some() || self.default_appearance.is_some())
213 .then_some(self)
214 }
215
216 pub fn style(&self, dark: bool) -> Option<&ThemeStyle> {
217 if dark {
218 self.dark.as_ref()
219 } else {
220 self.light.as_ref()
221 }
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
228#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
229pub enum SettingsDestination {
230 ControlAppPage {
231 #[serde(rename = "appId")]
232 app_id: String,
233 page: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 query: Option<BTreeMap<String, serde_json::Value>>,
236 },
237 BrowserControlPage {
238 route: String,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 query: Option<BTreeMap<String, serde_json::Value>>,
241 },
242 NativeAction {
243 #[serde(rename = "actionId")]
244 action_id: String,
245 },
246}
247
248impl SettingsDestination {
249 pub fn validate(&self) -> Result<(), String> {
250 match self {
251 Self::ControlAppPage {
252 app_id,
253 page,
254 query,
255 } => {
256 validate_settings_destination_field("appId", app_id)?;
257 validate_settings_destination_field("page", page)?;
258 validate_settings_destination_query(query.as_ref())
259 }
260 Self::BrowserControlPage { route, query } => {
261 validate_settings_destination_field("route", route)?;
262 validate_settings_destination_query(query.as_ref())
263 }
264 Self::NativeAction { action_id } => {
265 validate_settings_destination_field("actionId", action_id)
266 }
267 }
268 }
269}
270
271fn validate_settings_destination_field(name: &str, value: &str) -> Result<(), String> {
272 if value.trim().is_empty() {
273 return Err(format!("settingsDestination.{name} must not be empty"));
274 }
275 Ok(())
276}
277
278fn validate_settings_destination_query(
279 query: Option<&BTreeMap<String, serde_json::Value>>,
280) -> Result<(), String> {
281 let Some(query) = query else {
282 return Ok(());
283 };
284 for (key, value) in query {
285 if key.trim().is_empty() {
286 return Err("settingsDestination query keys must not be empty".to_string());
287 }
288 if !matches!(
289 value,
290 serde_json::Value::String(_)
291 | serde_json::Value::Number(_)
292 | serde_json::Value::Bool(_)
293 | serde_json::Value::Null
294 ) {
295 return Err(format!(
296 "settingsDestination query value for '{key}' must be a string, number, boolean, or null"
297 ));
298 }
299 }
300 Ok(())
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
304pub struct AppConfig {
305 #[serde(rename = "productName")]
306 pub product_name: String,
307 #[serde(
309 rename = "productNames",
310 default,
311 skip_serializing_if = "BTreeMap::is_empty"
312 )]
313 pub product_names: BTreeMap<String, String>,
314 #[serde(rename = "productVersion")]
315 pub product_version: String,
316
317 #[serde(rename = "lingxiaId", default)]
318 pub lingxia_id: Option<String>,
319
320 #[serde(rename = "lingxiaServer", default)]
321 pub lingxia_server: Option<String>,
322
323 #[serde(rename = "env", default)]
326 pub env: AppEnv,
327
328 #[serde(
329 rename = "homeAppId",
330 default,
331 skip_serializing_if = "String::is_empty"
332 )]
333 pub home_app_id: String,
334
335 #[serde(
336 rename = "homeAppVersion",
337 default,
338 skip_serializing_if = "String::is_empty"
339 )]
340 pub home_app_version: String,
341
342 #[serde(rename = "cacheMaxSizeMB", default = "default_cache_max_size_mb")]
343 pub cache_max_size_mb: u64,
344
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub storage: Option<StorageConfig>,
347
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub splash: Option<SplashConfig>,
350
351 #[serde(rename = "devWsUrl", default, skip_serializing_if = "Option::is_none")]
352 pub dev_ws_url: Option<String>,
353
354 #[serde(
355 rename = "devBundleBaseUrl",
356 default,
357 skip_serializing_if = "Option::is_none"
358 )]
359 pub dev_bundle_base_url: Option<String>,
360
361 #[serde(rename = "appLinks", default, skip_serializing_if = "Option::is_none")]
362 pub app_links: Option<AppLinksConfig>,
363
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub theme: Option<ThemeConfig>,
366
367 #[serde(
368 rename = "settingsDestination",
369 default,
370 skip_serializing_if = "Option::is_none"
371 )]
372 pub settings_destination: Option<SettingsDestination>,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub capabilities: Option<CapabilitiesConfig>,
376
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub panels: Option<PanelsConfig>,
379
380 #[serde(
384 rename = "updateTrustedPublicKeys",
385 default,
386 skip_serializing_if = "Vec::is_empty"
387 )]
388 pub update_trusted_public_keys: Vec<String>,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
398#[serde(rename_all = "camelCase", deny_unknown_fields)]
399pub struct CapabilitiesConfig {
400 #[serde(default)]
401 pub notifications: bool,
402 #[serde(default)]
405 pub browser: bool,
406 #[serde(default)]
407 pub terminal: bool,
408 #[serde(default)]
410 pub proxy: bool,
411 #[serde(default)]
414 pub process: bool,
415 #[serde(default)]
418 pub autostart: bool,
419 #[serde(default)]
425 pub app_use: bool,
426 #[serde(default)]
431 pub computer_use: bool,
432 #[serde(default)]
434 pub browser_use: bool,
435 #[serde(default, skip_serializing_if = "MediaCaptureConfig::is_empty")]
439 pub media_capture: MediaCaptureConfig,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
444#[serde(rename_all = "camelCase", deny_unknown_fields)]
445pub struct MediaCaptureConfig {
446 #[serde(default)]
447 pub visual: bool,
448 #[serde(default)]
449 pub system_audio: bool,
450 #[serde(default)]
451 pub microphone: bool,
452}
453
454impl MediaCaptureConfig {
455 pub fn is_enabled(&self) -> bool {
456 self.visual || self.system_audio || self.microphone
457 }
458
459 pub fn is_empty(&self) -> bool {
460 !self.is_enabled()
461 }
462}
463
464impl CapabilitiesConfig {
465 pub fn needs_control_socket(&self) -> bool {
469 self.app_use_effective() || self.browser_use
470 }
471
472 pub fn app_use_effective(&self) -> bool {
484 self.app_use || self.computer_use
485 }
486
487 pub fn media_capture_enabled(&self) -> bool {
488 self.media_capture.is_enabled()
489 }
490}
491
492#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
493pub struct AppLinksConfig {
494 #[serde(default, skip_serializing_if = "Vec::is_empty")]
495 pub hosts: Vec<String>,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
502#[serde(rename_all = "camelCase")]
503pub struct SplashConfig {
504 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub min_duration: Option<u32>,
506}
507
508pub const DEFAULT_SPLASH_MIN_DURATION_MS: u32 = 600;
511
512#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
513#[serde(rename_all = "camelCase")]
514pub struct StorageConfig {
515 #[serde(rename = "tempMaxSizeMB")]
516 #[serde(default = "default_temp_max_size_mb")]
517 pub temp_max_size_mb: u64,
518 #[serde(rename = "cacheMaxSizeMB")]
519 #[serde(default = "default_cache_max_size_mb")]
520 pub cache_max_size_mb: u64,
521 #[serde(rename = "dataMaxSizeMB")]
522 #[serde(default = "default_data_max_size_mb")]
523 pub data_max_size_mb: u64,
524 #[serde(rename = "appStorageMaxSizeMB")]
525 #[serde(default = "default_app_storage_max_size_mb")]
526 pub app_storage_max_size_mb: u64,
527}
528
529#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
530pub struct PanelsConfig {
531 #[serde(default, skip_serializing_if = "Vec::is_empty")]
532 pub items: Vec<PanelItem>,
533}
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
536#[serde(rename_all = "lowercase")]
537pub enum PanelPosition {
538 Left,
539 Right,
540 Top,
541 Bottom,
542}
543
544#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
545pub struct PanelItem {
546 pub id: String,
547 pub label: String,
548 pub icon: String,
549 #[serde(default = "default_panel_position")]
550 pub position: PanelPosition,
551 pub content: PanelContent,
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Default)]
555#[serde(rename_all = "lowercase")]
556pub enum PanelContentKind {
557 #[default]
558 LxApp,
559 Terminal,
560}
561
562impl PanelContentKind {
563 pub fn is_lxapp(self) -> bool {
564 self == PanelContentKind::LxApp
565 }
566}
567
568fn is_lxapp_panel_content_kind(kind: &PanelContentKind) -> bool {
569 kind.is_lxapp()
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
573pub struct PanelContent {
574 #[serde(default, skip_serializing_if = "is_lxapp_panel_content_kind")]
575 pub kind: PanelContentKind,
576 #[serde(rename = "appId")]
577 #[serde(default, skip_serializing_if = "String::is_empty")]
578 pub app_id: String,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub path: Option<String>,
581 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub page: Option<String>,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub query: Option<serde_json::Value>,
585}
586
587fn default_cache_max_size_mb() -> u64 {
588 2048
589}
590
591fn default_temp_max_size_mb() -> u64 {
592 1024
593}
594
595fn default_data_max_size_mb() -> u64 {
596 4096
597}
598
599fn default_app_storage_max_size_mb() -> u64 {
600 16384
601}
602
603fn default_panel_position() -> PanelPosition {
604 PanelPosition::Right
605}
606
607impl AppConfig {
608 pub fn parse_and_validate(content: &str) -> Result<Self, AppContextError> {
609 let mut config: Self = serde_json::from_str(content).map_err(|e| {
610 AppContextError::InvalidJson(format!("Failed to parse app.json: {}", e))
611 })?;
612 config.theme = config.theme.take().and_then(ThemeConfig::normalized);
613 config.validate()?;
614 Ok(config)
615 }
616
617 fn validate(&self) -> Result<(), AppContextError> {
618 if self.product_name.is_empty() {
619 return Err(AppContextError::InvalidConfig(
620 "productName is mandatory and cannot be empty".to_string(),
621 ));
622 }
623 if self.product_version.is_empty() {
624 return Err(AppContextError::InvalidConfig(
625 "productVersion is mandatory and cannot be empty".to_string(),
626 ));
627 }
628 Version::parse(&self.product_version).map_err(|_| {
629 AppContextError::InvalidConfig(
630 "productVersion must be a semantic version (major.minor.patch)".to_string(),
631 )
632 })?;
633 if self.home_app_id.is_empty() != self.home_app_version.is_empty() {
634 return Err(AppContextError::InvalidConfig(
635 "homeAppId and homeAppVersion must either both be set or both be omitted"
636 .to_string(),
637 ));
638 }
639 if !self.home_app_version.is_empty() {
640 Version::parse(&self.home_app_version).map_err(|_| {
641 AppContextError::InvalidConfig(
642 "homeAppVersion must be a semantic version (major.minor.patch)".to_string(),
643 )
644 })?;
645 }
646 if let Some(destination) = self.settings_destination.as_ref() {
647 destination
648 .validate()
649 .map_err(AppContextError::InvalidConfig)?;
650 }
651 validate_panels(self.panels.as_ref())
652 }
653
654 pub fn localized_product_name(&self, locale: &str) -> &str {
657 resolve_localized_product_name(&self.product_name, &self.product_names, locale)
658 }
659}
660
661static PRODUCT_NAME_LOCALE: OnceLock<fn() -> String> = OnceLock::new();
662
663pub fn set_product_name_locale(source: fn() -> String) {
666 let _ = PRODUCT_NAME_LOCALE.set(source);
667}
668
669pub fn set_app_config(config: AppConfig) -> Result<(), AppContextError> {
670 if let Some(existing) = APP_CONFIG.get() {
671 if existing == &config {
672 return Ok(());
673 }
674 return Err(AppContextError::InvalidConfig(
675 "app config is already initialized with different values".to_string(),
676 ));
677 }
678
679 APP_CONFIG
680 .set(config)
681 .map_err(|_| {
682 AppContextError::InvalidConfig(
683 "app config was initialized concurrently with different values".to_string(),
684 )
685 })
686 .map(|_| ())
687}
688
689pub fn app_config() -> Option<&'static AppConfig> {
690 APP_CONFIG.get()
691}
692
693pub fn theme() -> Option<&'static ThemeConfig> {
694 APP_CONFIG.get().and_then(|config| config.theme.as_ref())
695}
696
697static STARTUP: std::sync::LazyLock<std::time::Instant> =
700 std::sync::LazyLock::new(std::time::Instant::now);
701
702pub fn mark_startup() {
704 let _ = *STARTUP;
705}
706
707pub fn since_startup() -> std::time::Duration {
708 STARTUP.elapsed()
709}
710
711static SPLASH_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
714
715pub fn mark_splash_visible() {
726 SPLASH_VISIBLE.store(true, std::sync::atomic::Ordering::Relaxed);
727}
728
729pub fn splash_visible_for() -> Option<std::time::Duration> {
742 SPLASH_VISIBLE
743 .load(std::sync::atomic::Ordering::Relaxed)
744 .then(since_startup)
745}
746
747const SPLASH_HOLD_CAP_MS: u32 = 6_000;
748
749pub fn splash_min_duration() -> std::time::Duration {
751 let ms = APP_CONFIG
752 .get()
753 .and_then(|config| config.splash.as_ref())
754 .and_then(|splash| splash.min_duration)
755 .unwrap_or(DEFAULT_SPLASH_MIN_DURATION_MS)
756 .min(SPLASH_HOLD_CAP_MS);
761 std::time::Duration::from_millis(u64::from(ms))
762}
763
764struct CampaignHandoff {
766 pending: Option<(String, u32)>,
767 closed: bool,
768}
769
770impl CampaignHandoff {
771 const fn new() -> Self {
772 Self {
773 pending: None,
774 closed: false,
775 }
776 }
777
778 fn offer(&mut self, image_path: String, duration_ms: u32) -> bool {
779 if self.closed {
780 return false;
781 }
782 self.pending = Some((image_path, duration_ms));
783 true
784 }
785
786 fn take_and_close(&mut self) -> Option<(String, u32)> {
787 self.closed = true;
788 self.pending.take()
789 }
790}
791
792static CAMPAIGN_HANDOFF: std::sync::Mutex<CampaignHandoff> =
793 std::sync::Mutex::new(CampaignHandoff::new());
794
795pub fn set_pending_campaign(image_path: String, duration_ms: u32) -> bool {
799 CAMPAIGN_HANDOFF
800 .lock()
801 .unwrap_or_else(|error| error.into_inner())
802 .offer(image_path, duration_ms)
803}
804
805pub fn take_pending_campaign() -> Option<(String, u32)> {
809 CAMPAIGN_HANDOFF
810 .lock()
811 .unwrap_or_else(|error| error.into_inner())
812 .take_and_close()
813}
814
815pub fn page_background_color(dark: bool) -> Option<String> {
820 theme()?
821 .style(dark)?
822 .page_background_color
823 .map(|color| color.to_string())
824}
825
826pub fn product_name() -> Option<&'static str> {
827 let config = APP_CONFIG.get()?;
828 let locale = PRODUCT_NAME_LOCALE
829 .get()
830 .map(|source| source())
831 .unwrap_or_default();
832 Some(config.localized_product_name(&locale))
833}
834
835pub fn resolve_localized_product_name<'a>(
836 default: &'a str,
837 names: &'a BTreeMap<String, String>,
838 locale: &str,
839) -> &'a str {
840 if names.is_empty() {
841 return default;
842 }
843 let Some(canonical) = canonical_system_locale(locale) else {
844 return default;
845 };
846 for candidate in locale_lookup_candidates(&canonical) {
847 if let Some(name) = names.get(&candidate) {
848 return name;
849 }
850 if let Some((_, name)) = names
851 .iter()
852 .find(|(key, _)| key.eq_ignore_ascii_case(&candidate))
853 {
854 return name;
855 }
856 }
857 default
858}
859
860fn canonical_system_locale(locale: &str) -> Option<String> {
861 let base = locale
862 .split(['@', '.'])
863 .next()
864 .unwrap_or_default()
865 .replace('_', "-");
866 let trimmed = base.trim();
867 if trimmed.is_empty() {
868 return None;
869 }
870 let parsed = language_tags::LanguageTag::parse(trimmed).ok()?;
871 parsed.validate().ok()?;
872 parsed.canonicalize().ok().map(|tag| tag.into_string())
873}
874
875fn locale_lookup_candidates(canonical: &str) -> Vec<String> {
876 let mut out = vec![canonical.to_string()];
877 let parts: Vec<&str> = canonical.split('-').collect();
878 if let Some(lang) = parts.first()
879 && let Some(region) = parts
880 .iter()
881 .rev()
882 .find(|part| part.len() == 2 && part.bytes().all(|b| b.is_ascii_alphabetic()))
883 {
884 let language_region = format!("{lang}-{region}");
885 if !out.iter().any(|candidate| candidate == &language_region) {
886 out.push(language_region);
887 }
888 }
889 let mut rest = canonical;
890 while let Some((head, _)) = rest.rsplit_once('-') {
891 if !out.iter().any(|candidate| candidate == head) {
892 out.push(head.to_string());
893 }
894 rest = head;
895 }
896 out
897}
898
899pub fn home_app_id() -> Option<&'static str> {
900 APP_CONFIG
901 .get()
902 .map(|c| c.home_app_id.as_str())
903 .filter(|value| !value.is_empty())
904}
905
906pub fn home_app_version() -> Option<&'static str> {
907 APP_CONFIG
908 .get()
909 .map(|c| c.home_app_version.as_str())
910 .filter(|value| !value.is_empty())
911}
912
913pub fn product_version() -> Option<&'static str> {
914 APP_CONFIG.get().map(|c| c.product_version.as_str())
915}
916
917pub fn lingxia_id() -> Option<&'static str> {
918 APP_CONFIG
919 .get()
920 .and_then(|c| c.lingxia_id.as_deref())
921 .filter(|s| !s.is_empty())
922}
923
924pub fn env() -> AppEnv {
927 APP_CONFIG.get().map(|c| c.env).unwrap_or_default()
928}
929
930pub fn notifications_enabled() -> bool {
931 APP_CONFIG
932 .get()
933 .and_then(|c| c.capabilities.as_ref())
934 .map(|capabilities| capabilities.notifications)
935 .unwrap_or(false)
936}
937
938pub fn browser_enabled() -> bool {
939 APP_CONFIG
940 .get()
941 .and_then(|config| config.capabilities.as_ref())
942 .map(|capabilities| capabilities.browser)
943 .unwrap_or(false)
944}
945
946fn capabilities_config() -> Option<&'static CapabilitiesConfig> {
948 APP_CONFIG
949 .get()
950 .and_then(|config| config.capabilities.as_ref())
951}
952
953pub fn proxy_enabled() -> bool {
954 APP_CONFIG
955 .get()
956 .and_then(|config| config.capabilities.as_ref())
957 .map(|capabilities| capabilities.proxy)
958 .unwrap_or(false)
959}
960
961pub fn autostart_enabled() -> bool {
962 APP_CONFIG
963 .get()
964 .and_then(|c| c.capabilities.as_ref())
965 .map(|capabilities| capabilities.autostart)
966 .unwrap_or(false)
967}
968
969pub fn terminal_enabled() -> bool {
970 APP_CONFIG
971 .get()
972 .and_then(|c| c.capabilities.as_ref())
973 .map(|capabilities| capabilities.terminal)
974 .unwrap_or(false)
975}
976
977#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
984pub struct HostBuild {
985 pub browser: bool,
986 pub terminal: bool,
987 pub proxy: bool,
988}
989
990static HOST_BUILD: OnceLock<HostBuild> = OnceLock::new();
991
992pub fn set_host_build(build: HostBuild) {
994 let _ = HOST_BUILD.set(build);
995}
996
997pub fn host_build() -> HostBuild {
998 HOST_BUILD.get().copied().unwrap_or_default()
999}
1000
1001pub fn host_build_recorded() -> bool {
1004 HOST_BUILD.get().is_some()
1005}
1006
1007pub mod capability {
1011 pub mod build {
1014 pub fn browser() -> bool {
1015 super::super::host_build().browser
1016 }
1017
1018 pub fn terminal() -> bool {
1019 super::super::host_build().terminal
1020 }
1021
1022 pub fn proxy() -> bool {
1023 super::super::host_build().proxy
1024 }
1025
1026 pub fn notifications() -> bool {
1028 cfg!(any(target_os = "ios", target_env = "ohos"))
1029 }
1030 }
1031
1032 pub fn browser() -> bool {
1034 build::browser() && super::browser_enabled()
1035 }
1036
1037 pub fn notifications() -> bool {
1039 build::notifications() && super::notifications_enabled()
1040 }
1041
1042 pub fn app_use() -> bool {
1046 super::capabilities_config()
1047 .map(|capabilities| capabilities.app_use_effective())
1048 .unwrap_or(false)
1049 }
1050
1051 pub fn computer_use() -> bool {
1053 super::capabilities_config()
1054 .map(|capabilities| capabilities.computer_use)
1055 .unwrap_or(false)
1056 }
1057
1058 pub fn browser_use() -> bool {
1062 browser()
1063 && super::capabilities_config()
1064 .map(|capabilities| capabilities.browser_use)
1065 .unwrap_or(false)
1066 }
1067
1068 pub fn media_capture() -> bool {
1070 super::capabilities_config()
1071 .map(|capabilities| capabilities.media_capture_enabled())
1072 .unwrap_or(false)
1073 }
1074
1075 pub fn proxy() -> bool {
1079 build::proxy() && super::proxy_enabled() && super::browser_enabled()
1080 }
1081}
1082
1083pub fn process_enabled() -> bool {
1084 APP_CONFIG
1085 .get()
1086 .and_then(|c| c.capabilities.as_ref())
1087 .map(|capabilities| capabilities.process)
1088 .unwrap_or(false)
1089}
1090
1091pub fn temp_max_size_bytes() -> u64 {
1092 const MIB: u64 = 1024 * 1024;
1093 APP_CONFIG
1094 .get()
1095 .and_then(|c| c.storage.as_ref().map(|storage| storage.temp_max_size_mb))
1096 .unwrap_or_else(default_temp_max_size_mb)
1097 .saturating_mul(MIB)
1098}
1099
1100pub fn cache_max_size_bytes() -> u64 {
1101 const MIB: u64 = 1024 * 1024;
1102 APP_CONFIG
1103 .get()
1104 .map(|c| {
1105 c.storage
1106 .as_ref()
1107 .map(|storage| storage.cache_max_size_mb)
1108 .unwrap_or(c.cache_max_size_mb)
1109 })
1110 .unwrap_or_else(default_cache_max_size_mb)
1111 .saturating_mul(MIB)
1112}
1113
1114pub fn data_max_size_bytes() -> u64 {
1115 const MIB: u64 = 1024 * 1024;
1116 APP_CONFIG
1117 .get()
1118 .and_then(|c| c.storage.as_ref().map(|storage| storage.data_max_size_mb))
1119 .unwrap_or_else(default_data_max_size_mb)
1120 .saturating_mul(MIB)
1121}
1122
1123pub fn app_storage_max_size_bytes() -> u64 {
1124 const MIB: u64 = 1024 * 1024;
1125 APP_CONFIG
1126 .get()
1127 .and_then(|c| {
1128 c.storage
1129 .as_ref()
1130 .map(|storage| storage.app_storage_max_size_mb)
1131 })
1132 .unwrap_or_else(default_app_storage_max_size_mb)
1133 .saturating_mul(MIB)
1134}
1135
1136pub fn app_state_dir(app_data_dir: &Path) -> PathBuf {
1137 app_data_dir.join(APP_STATE_DIR)
1138}
1139
1140pub fn app_state_file(app_data_dir: &Path, name: &str) -> PathBuf {
1141 app_state_dir(app_data_dir).join(name)
1142}
1143
1144fn validate_panels(panels: Option<&PanelsConfig>) -> Result<(), AppContextError> {
1145 let Some(panels) = panels else {
1146 return Ok(());
1147 };
1148
1149 let mut ids = HashSet::new();
1150 let mut positions = HashSet::new();
1151 let mut app_ids = HashSet::new();
1152
1153 for item in &panels.items {
1154 if item.id.is_empty() {
1155 return Err(AppContextError::InvalidConfig(
1156 "panels.items[].id cannot be empty".to_string(),
1157 ));
1158 }
1159 if item.label.is_empty() {
1160 return Err(AppContextError::InvalidConfig(format!(
1161 "panel '{}' label cannot be empty",
1162 item.id
1163 )));
1164 }
1165 if item.content.kind == PanelContentKind::LxApp && item.content.app_id.is_empty() {
1166 return Err(AppContextError::InvalidConfig(format!(
1167 "panel '{}' content.appId cannot be empty",
1168 item.id
1169 )));
1170 }
1171 if !ids.insert(item.id.clone()) {
1172 return Err(AppContextError::InvalidConfig(format!(
1173 "duplicate panel id '{}'",
1174 item.id
1175 )));
1176 }
1177 if !positions.insert(item.position) {
1178 return Err(AppContextError::InvalidConfig(format!(
1179 "only one panel is supported at position '{}'",
1180 panel_position_name(item.position)
1181 )));
1182 }
1183 if item.content.kind == PanelContentKind::LxApp
1184 && !app_ids.insert(item.content.app_id.clone())
1185 {
1186 return Err(AppContextError::InvalidConfig(format!(
1187 "panel appId '{}' must be unique",
1188 item.content.app_id
1189 )));
1190 }
1191 }
1192
1193 Ok(())
1194}
1195
1196fn panel_position_name(position: PanelPosition) -> &'static str {
1197 match position {
1198 PanelPosition::Left => "left",
1199 PanelPosition::Right => "right",
1200 PanelPosition::Top => "top",
1201 PanelPosition::Bottom => "bottom",
1202 }
1203}
1204
1205#[cfg(test)]
1206mod tests {
1207 use super::{
1208 AppConfig, AppContextError, CampaignHandoff, SettingsDestination, ThemeColor, ThemeConfig,
1209 set_app_config,
1210 };
1211
1212 fn test_config(product_name: &str) -> AppConfig {
1213 AppConfig {
1214 product_name: product_name.to_string(),
1215 product_names: Default::default(),
1216 product_version: "1.0.0".to_string(),
1217 lingxia_id: Some("lingxia".to_string()),
1218 lingxia_server: None,
1219 env: super::AppEnv::Prod,
1220 home_app_id: "home".to_string(),
1221 home_app_version: "1.0.0".to_string(),
1222 cache_max_size_mb: 1024,
1223 storage: None,
1224 splash: None,
1225 dev_ws_url: None,
1226 dev_bundle_base_url: None,
1227 app_links: None,
1228 theme: None,
1229 settings_destination: None,
1230 capabilities: None,
1231 panels: None,
1232 update_trusted_public_keys: Vec::new(),
1233 }
1234 }
1235
1236 #[test]
1237 fn set_app_config_rejects_mismatched_value_after_initialization() {
1238 let cfg = test_config("LingXia");
1239 assert!(set_app_config(cfg.clone()).is_ok());
1240 assert!(set_app_config(cfg).is_ok());
1241 let err = set_app_config(test_config("Other")).unwrap_err();
1242 assert!(matches!(err, AppContextError::InvalidConfig(_)));
1243 }
1244
1245 #[test]
1246 fn parse_and_validate_reads_update_trusted_public_keys() {
1247 let config = AppConfig::parse_and_validate(
1248 r#"{
1249 "productName": "Demo",
1250 "productVersion": "1.0.0",
1251 "updateTrustedPublicKeys": ["6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw"]
1252 }"#,
1253 )
1254 .expect("app.json with update keys");
1255 assert_eq!(
1256 config.update_trusted_public_keys,
1257 vec!["6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw".to_string()]
1258 );
1259 }
1260
1261 #[test]
1262 fn host_without_home_lxapp_is_valid() {
1263 let mut config = test_config("Web Host");
1264 config.home_app_id.clear();
1265 config.home_app_version.clear();
1266
1267 let json = serde_json::to_string(&config).unwrap();
1268 assert!(!json.contains("homeAppId"));
1269 assert!(!json.contains("homeAppVersion"));
1270 assert!(AppConfig::parse_and_validate(&json).is_ok());
1271 }
1272
1273 #[test]
1274 fn home_lxapp_identity_must_be_complete() {
1275 let mut config = test_config("Broken Host");
1276 config.home_app_version.clear();
1277
1278 let error = config.validate().unwrap_err();
1279 assert!(matches!(error, AppContextError::InvalidConfig(_)));
1280 }
1281
1282 #[test]
1283 fn theme_colors_validate_and_serialize_canonically() {
1284 let config = AppConfig::parse_and_validate(
1285 r##"{
1286 "productName": "Theme Test",
1287 "productVersion": "1.0.0",
1288 "theme": {
1289 "light": { "accentColor": "#a1b2c3" },
1290 "dark": { "separatorColor": "#343840" }
1291 }
1292 }"##,
1293 )
1294 .expect("valid theme");
1295
1296 let light = config
1297 .theme
1298 .as_ref()
1299 .and_then(|theme| theme.light.as_ref())
1300 .expect("light style");
1301 assert_eq!(light.accent_color.map(ThemeColor::rgb), Some(0xA1B2C3));
1302
1303 let json = serde_json::to_string(&config).expect("serialize app config");
1304 assert!(json.contains("#A1B2C3"));
1305 }
1306
1307 #[test]
1308 fn theme_rejects_alpha_and_unknown_fields() {
1309 for theme in [
1310 r##"{ "light": { "accentColor": "#80A1B2C3" } }"##,
1311 r##"{ "light": { "sidebarBackgroundColor": "#A1B2C3" } }"##,
1312 r##"{ "highContrast": { "accentColor": "#A1B2C3" } }"##,
1313 ] {
1314 let json = format!(
1315 r#"{{ "productName": "Theme Test", "productVersion": "1.0.0", "theme": {theme} }}"#
1316 );
1317 assert!(AppConfig::parse_and_validate(&json).is_err(), "{theme}");
1318 }
1319 }
1320
1321 #[test]
1322 fn empty_theme_blocks_normalize_to_absence() {
1323 let theme: ThemeConfig =
1324 serde_json::from_str(r#"{ "light": {}, "dark": {} }"#).expect("parse empty theme");
1325 assert!(theme.normalized().is_none());
1326 }
1327
1328 #[test]
1329 fn missing_env_defaults_to_prod() {
1330 let config = AppConfig::parse_and_validate(
1331 r#"{
1332 "productName": "Env Test",
1333 "productVersion": "1.0.0"
1334 }"#,
1335 )
1336 .expect("valid app.json");
1337 assert_eq!(config.env, super::AppEnv::Prod);
1338 }
1339
1340 #[test]
1341 fn env_version_is_not_an_alias_for_env() {
1342 let config = AppConfig::parse_and_validate(
1343 r#"{
1344 "productName": "Env Test",
1345 "productVersion": "1.0.0",
1346 "envVersion": "developer"
1347 }"#,
1348 )
1349 .expect("unknown fields are ignored; missing env is prod");
1350 assert_eq!(config.env, super::AppEnv::Prod);
1351 }
1352
1353 #[test]
1354 fn env_rejects_channel_names() {
1355 for env in ["developer", "preview", "release"] {
1356 let json = format!(
1357 r#"{{ "productName": "Env Test", "productVersion": "1.0.0", "env": "{env}" }}"#
1358 );
1359 assert!(
1360 AppConfig::parse_and_validate(&json).is_err(),
1361 "env={env} must not parse"
1362 );
1363 }
1364 }
1365
1366 #[test]
1367 fn default_appearance_parses_and_round_trips() {
1368 let config = AppConfig::parse_and_validate(
1369 r#"{
1370 "productName": "Theme Test",
1371 "productVersion": "1.0.0",
1372 "theme": { "defaultAppearance": "dark" }
1373 }"#,
1374 )
1375 .expect("valid default appearance");
1376 let theme = config
1377 .theme
1378 .as_ref()
1379 .expect("a theme with only a default is kept");
1380 assert_eq!(
1381 theme.default_appearance,
1382 Some(super::AppearancePreference::Dark)
1383 );
1384
1385 let json = serde_json::to_value(&config).expect("serialize app config");
1386 assert_eq!(json["theme"]["defaultAppearance"], "dark");
1387 }
1388
1389 #[test]
1390 fn default_appearance_rejects_values_outside_auto_light_dark() {
1391 let json = r#"{
1392 "productName": "Theme Test",
1393 "productVersion": "1.0.0",
1394 "theme": { "defaultAppearance": "black" }
1395 }"#;
1396 assert!(AppConfig::parse_and_validate(json).is_err());
1397 }
1398
1399 #[test]
1400 fn an_explicit_auto_default_normalizes_like_no_default() {
1401 let theme: ThemeConfig =
1402 serde_json::from_str(r#"{ "defaultAppearance": "auto" }"#).expect("parse theme");
1403 assert!(theme.normalized().is_none());
1404
1405 let theme: ThemeConfig = serde_json::from_str(
1406 r##"{ "defaultAppearance": "auto", "light": { "accentColor": "#A1B2C3" } }"##,
1407 )
1408 .expect("parse theme");
1409 let theme = theme.normalized().expect("colors keep the theme");
1410 assert_eq!(theme.default_appearance, None);
1411 }
1412
1413 #[test]
1414 fn settings_destination_is_optional_and_round_trips_all_variants() {
1415 let config = test_config("Settings Test");
1416 let json = serde_json::to_value(&config).expect("serialize config without destination");
1417 assert!(json.get("settingsDestination").is_none());
1418
1419 let variants = [
1420 serde_json::json!({
1421 "kind": "controlAppPage",
1422 "appId": "com.example.control",
1423 "page": "settings",
1424 "query": { "tab": "general", "enabled": true, "count": 2, "empty": null }
1425 }),
1426 serde_json::json!({
1427 "kind": "browserControlPage",
1428 "route": "/settings/privacy",
1429 "query": { "source": "menu" }
1430 }),
1431 serde_json::json!({ "kind": "nativeAction", "actionId": "openPreferences" }),
1432 ];
1433
1434 for destination_json in variants {
1435 let destination: SettingsDestination =
1436 serde_json::from_value(destination_json.clone()).expect("decode destination");
1437 destination.validate().expect("valid destination");
1438 assert_eq!(
1439 serde_json::to_value(destination).expect("encode destination"),
1440 destination_json
1441 );
1442 }
1443 }
1444
1445 #[test]
1446 fn settings_destination_schema_and_values_are_strict() {
1447 for destination in [
1448 serde_json::json!({
1449 "kind": "controlAppPage",
1450 "appId": "control",
1451 "page": "settings",
1452 "extra": true
1453 }),
1454 serde_json::json!({ "kind": "browserControlPage", "route": "/settings", "appId": "wrong" }),
1455 serde_json::json!({ "kind": "nativeAction", "actionId": "open", "query": {} }),
1456 ] {
1457 assert!(
1458 serde_json::from_value::<SettingsDestination>(destination).is_err(),
1459 "unknown fields must be rejected"
1460 );
1461 }
1462
1463 for destination in [
1464 serde_json::json!({ "kind": "controlAppPage", "appId": " ", "page": "settings" }),
1465 serde_json::json!({ "kind": "controlAppPage", "appId": "control", "page": "" }),
1466 serde_json::json!({ "kind": "browserControlPage", "route": " " }),
1467 serde_json::json!({ "kind": "nativeAction", "actionId": "" }),
1468 serde_json::json!({
1469 "kind": "browserControlPage",
1470 "route": "/settings",
1471 "query": { " ": "value" }
1472 }),
1473 serde_json::json!({
1474 "kind": "browserControlPage",
1475 "route": "/settings",
1476 "query": { "nested": { "not": "scalar" } }
1477 }),
1478 ] {
1479 let app_json = serde_json::json!({
1480 "productName": "Settings Test",
1481 "productVersion": "1.0.0",
1482 "settingsDestination": destination
1483 });
1484 let error = AppConfig::parse_and_validate(&app_json.to_string())
1485 .expect_err("invalid settings destination");
1486 assert!(matches!(error, AppContextError::InvalidConfig(_)));
1487 }
1488 }
1489
1490 #[test]
1491 fn campaign_handoff_drops_an_answer_after_home_is_ready() {
1492 let mut handoff = CampaignHandoff::new();
1493 assert!(handoff.offer("first.png".to_string(), 1_500));
1494 assert_eq!(
1495 handoff.take_and_close(),
1496 Some(("first.png".to_string(), 1_500))
1497 );
1498 assert!(!handoff.offer("late.png".to_string(), 3_000));
1499 assert_eq!(handoff.take_and_close(), None);
1500 }
1501
1502 #[test]
1503 fn localized_product_name_matches_exact_then_language_then_default() {
1504 let mut names = std::collections::BTreeMap::new();
1505 names.insert("zh-CN".to_string(), "我的应用".to_string());
1506 names.insert("en-US".to_string(), "My App".to_string());
1507 assert_eq!(
1508 super::resolve_localized_product_name("My App", &names, "zh-CN"),
1509 "我的应用"
1510 );
1511 assert_eq!(
1512 super::resolve_localized_product_name("My App", &names, "zh_CN.UTF-8"),
1513 "我的应用"
1514 );
1515 assert_eq!(
1516 super::resolve_localized_product_name("My App", &names, "zh-Hans-CN"),
1517 "我的应用"
1518 );
1519 assert_eq!(
1520 super::resolve_localized_product_name("My App", &names, "en-US"),
1521 "My App"
1522 );
1523 assert_eq!(
1524 super::resolve_localized_product_name("My App", &names, "fr-FR"),
1525 "My App"
1526 );
1527 }
1528}