Skip to main content

lingxia_app_context/
lib.rs

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/// Build-time environment version baked into `app.json`.
21///
22/// Wire-compatible with `lingxia_update::ReleaseType` — both serialize as
23/// lowercase `"developer" | "preview" | "release"`. Defined locally here
24/// (rather than imported) to keep `lingxia-app-context` free of additional
25/// crate dependencies; the JSON contract is what callers rely on.
26#[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/// Opaque sRGB color used by the host theme wire format.
52#[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    /// The environment this build was produced for. Defaults to [`EnvVersion::Release`]
165    /// when missing, matching pre-envVersion app.json artifacts.
166    #[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/// The `capabilities:` section, shared verbatim between the CLI (parsing
216/// `lingxia.yaml`, writing `app.json`) and the runtime (reading `app.json`) —
217/// one definition so a capability can never exist on one side only.
218/// `deny_unknown_fields` gives lingxia.yaml typo errors; the runtime always
219/// reads an app.json generated by the same CLI build, so it never sees fields
220/// this struct lacks.
221#[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    /// The product in-app browser, with its newtab / settings / downloads pages
227    /// and browser shell runtime. Opt-in and cross-platform.
228    #[serde(default)]
229    pub browser: bool,
230    #[serde(default)]
231    pub terminal: bool,
232    /// Opt-in HTTP proxy for the in-app browser (desktop). Requires browser.
233    #[serde(default)]
234    pub proxy: bool,
235    /// Allows the trusted home lxapp to launch and manage OS processes. The
236    /// lxapp must also declare the `process` security privilege.
237    #[serde(default)]
238    pub process: bool,
239    /// Unlocks `lx.app.autostart` (launch at system startup). macOS/Windows
240    /// only; enabling is always a runtime user decision, never automatic.
241    #[serde(default)]
242    pub autostart: bool,
243    /// Lets a command line or agent skill on the same machine drive this
244    /// product's own windows, and unlocks the product's command line. Desktop
245    /// only. The local socket it needs is derived, not declared: which IPC
246    /// carries this is plumbing, and a capability list says what a product can
247    /// do.
248    #[serde(default)]
249    pub app_use: bool,
250    /// Extends that to the whole machine: screenshots of any window, synthetic
251    /// input, the accessibility tree. Named for what the user is granting,
252    /// because they will be asked — macOS prompts for Accessibility and Screen
253    /// Recording, and the entry they see in System Settings is this product.
254    #[serde(default)]
255    pub computer_use: bool,
256    /// Extends it to the in-app browser. Requires `browser`.
257    #[serde(default)]
258    pub browser_use: bool,
259    /// Realtime visual / system-audio / microphone capture. Independent of
260    /// `computerUse`. Omit the key, or leave every track false, for no
261    /// provider, services, or entitlements.
262    #[serde(default, skip_serializing_if = "MediaCaptureConfig::is_empty")]
263    pub media_capture: MediaCaptureConfig,
264}
265
266/// Declared realtime-capture tracks. Each track is independently optional.
267#[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    /// Whether anything needs the local control socket. Derived rather than
290    /// declared: no product should have to know the transport's name to say
291    /// what it wants.
292    pub fn needs_control_socket(&self) -> bool {
293        self.app_use_effective() || self.browser_use
294    }
295
296    /// Whether this product's own windows may be driven.
297    ///
298    /// `computerUse` implies it. Not for symmetry — because it already
299    /// contains it: an agent that may screenshot any window and post input to
300    /// any window can reach this product's through the wider door. Requiring
301    /// both would add no protection and one failure mode, where a product
302    /// declares `computerUse`, forgets `appUse`, and `myapp computer
303    /// screenshot` works while `myapp screenshot` is refused.
304    ///
305    /// `browserUse` does not imply it: driving browser tabs reaches no native
306    /// window, and "open pages, don't touch my chrome" is a real choice.
307    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/// Runtime half of `splash:`. Images and colors are platform resources; only
323/// the minimum hold time is a runtime decision, and the upper bound is a
324/// framework constant that hosts deliberately cannot configure.
325#[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
332/// Default minimum hold, in milliseconds. Long enough that a fast first render
333/// does not flash the cover, short enough not to feel like a delay.
334pub 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
498/// Wall-clock origin for cold-start timing. First touched while the runtime
499/// loads `app.json`, which is early enough to stand in for process start.
500static STARTUP: std::sync::LazyLock<std::time::Instant> =
501    std::sync::LazyLock::new(std::time::Instant::now);
502
503/// Start the cold-start clock. Idempotent; call as early as possible.
504pub fn mark_startup() {
505    let _ = *STARTUP;
506}
507
508pub fn since_startup() -> std::time::Duration {
509    STARTUP.elapsed()
510}
511
512/// A host's per-launch override of the minimum hold (`u32::MAX` = unset).
513/// Bounded by the platforms' 6s dismissal timeout, which stays absolute.
514static 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
518/// Override the configured minimum hold for this launch, from the host's
519/// splash selection hook.
520pub 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
527/// How long the splash must stay up before a ready signal may dismiss it.
528pub 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
570/// Active environment version baked into the running build. Defaults to
571/// [`EnvVersion::Release`] before [`set_app_config`] is called and for any
572/// `app.json` produced before the envVersion field existed.
573pub 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
593/// The declared capability block, when this product shipped one.
594fn 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/// What the host *binary* was compiled with, recorded once at boot.
625///
626/// A capability is available only when the build carries it and the app
627/// declares it in `lingxia.yaml`; the declaration accessors above answer the
628/// second half. Defaults to all-false so a host that never records its build
629/// (tests, tools) reports nothing rather than over-promising.
630#[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
639/// Records the host build's capabilities. Idempotent; the first call wins.
640pub 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
648/// Whether the boot recorded the build yet. Lets a consumer assert it is not
649/// reading the all-false default before `set_host_build` ran.
650pub fn host_build_recorded() -> bool {
651    HOST_BUILD.get().is_some()
652}
653
654/// The one place each host capability is decided. `lx.supports()`, the FFI
655/// capability bitmask, and the optional `lx.*` members all read these, so they
656/// cannot drift apart.
657pub mod capability {
658    /// What the binary carries, independent of what an app declared. The
659    /// native SDKs' capability bitmask reports this.
660    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        /// Notifications are a platform fact rather than a build feature.
674        pub fn notifications() -> bool {
675            cfg!(any(target_os = "ios", target_env = "ohos"))
676        }
677    }
678
679    /// Managed browser tabs and the browser shell.
680    pub fn browser() -> bool {
681        build::browser() && super::browser_enabled()
682    }
683
684    /// Host notifications.
685    pub fn notifications() -> bool {
686        build::notifications() && super::notifications_enabled()
687    }
688
689    /// Driving this product's own windows and its command line. `computerUse`
690    /// already contains it, so it answers for both — the same rule the local
691    /// control surface enforces.
692    pub fn app_use() -> bool {
693        super::capabilities_config()
694            .map(|capabilities| capabilities.app_use_effective())
695            .unwrap_or(false)
696    }
697
698    /// Driving the whole machine: screenshots, synthetic input, the a11y tree.
699    pub fn computer_use() -> bool {
700        super::capabilities_config()
701            .map(|capabilities| capabilities.computer_use)
702            .unwrap_or(false)
703    }
704
705    /// Driving the in-app browser's tabs. `capabilities.browser` is the
706    /// prerequisite — there is nothing to drive without the engine — so both
707    /// have to be on. The declared flag alone would let `lx.supports` lie.
708    pub fn browser_use() -> bool {
709        browser()
710            && super::capabilities_config()
711                .map(|capabilities| capabilities.browser_use)
712                .unwrap_or(false)
713    }
714
715    /// Realtime capture tracks declared by the host.
716    pub fn media_capture() -> bool {
717        super::capabilities_config()
718            .map(|capabilities| capabilities.media_capture_enabled())
719            .unwrap_or(false)
720    }
721
722    /// The in-app browser's HTTP proxy. `capabilities.proxy` declares it and
723    /// `capabilities.browser` is its prerequisite — a proxy with nothing to
724    /// proxy is not a capability — so both have to be on.
725    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}