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    /// The page floor: the colour an lxapp's own CSS paints its page with.
105    ///
106    /// The host declares it because native chrome has to agree with it in
107    /// places the page cannot reach — the strip a pull-to-refresh opens above
108    /// the page, the container a navigation transition slides views across —
109    /// and no platform can ask a WebView what colour its document is early
110    /// enough to paint the frame the user is already looking at. Unset falls
111    /// back to the platform's own system background, which is what every host
112    /// got before this existed.
113    #[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    /// The environment this build was produced for. Defaults to [`EnvVersion::Release`]
176    /// when missing, matching pre-envVersion app.json artifacts.
177    #[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/// The `capabilities:` section, shared verbatim between the CLI (parsing
227/// `lingxia.yaml`, writing `app.json`) and the runtime (reading `app.json`) —
228/// one definition so a capability can never exist on one side only.
229/// `deny_unknown_fields` gives lingxia.yaml typo errors; the runtime always
230/// reads an app.json generated by the same CLI build, so it never sees fields
231/// this struct lacks.
232#[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    /// The product in-app browser, with its newtab / settings / downloads pages
238    /// and browser shell runtime. Opt-in and cross-platform.
239    #[serde(default)]
240    pub browser: bool,
241    #[serde(default)]
242    pub terminal: bool,
243    /// Opt-in HTTP proxy for the in-app browser (desktop). Requires browser.
244    #[serde(default)]
245    pub proxy: bool,
246    /// Allows the trusted home lxapp to launch and manage OS processes. The
247    /// lxapp must also declare the `process` security privilege.
248    #[serde(default)]
249    pub process: bool,
250    /// Unlocks `lx.app.autostart` (launch at system startup). macOS/Windows
251    /// only; enabling is always a runtime user decision, never automatic.
252    #[serde(default)]
253    pub autostart: bool,
254    /// Lets a command line or agent skill on the same machine drive this
255    /// product's own windows, and unlocks the product's command line. Desktop
256    /// only. The local socket it needs is derived, not declared: which IPC
257    /// carries this is plumbing, and a capability list says what a product can
258    /// do.
259    #[serde(default)]
260    pub app_use: bool,
261    /// Extends that to the whole machine: screenshots of any window, synthetic
262    /// input, the accessibility tree. Named for what the user is granting,
263    /// because they will be asked — macOS prompts for Accessibility and Screen
264    /// Recording, and the entry they see in System Settings is this product.
265    #[serde(default)]
266    pub computer_use: bool,
267    /// Extends it to the in-app browser. Requires `browser`.
268    #[serde(default)]
269    pub browser_use: bool,
270    /// Realtime visual / system-audio / microphone capture. Independent of
271    /// `computerUse`. Omit the key, or leave every track false, for no
272    /// provider, services, or entitlements.
273    #[serde(default, skip_serializing_if = "MediaCaptureConfig::is_empty")]
274    pub media_capture: MediaCaptureConfig,
275}
276
277/// Declared realtime-capture tracks. Each track is independently optional.
278#[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    /// Whether anything needs the local control socket. Derived rather than
301    /// declared: no product should have to know the transport's name to say
302    /// what it wants.
303    pub fn needs_control_socket(&self) -> bool {
304        self.app_use_effective() || self.browser_use
305    }
306
307    /// Whether this product's own windows may be driven.
308    ///
309    /// `computerUse` implies it. Not for symmetry — because it already
310    /// contains it: an agent that may screenshot any window and post input to
311    /// any window can reach this product's through the wider door. Requiring
312    /// both would add no protection and one failure mode, where a product
313    /// declares `computerUse`, forgets `appUse`, and `myapp computer
314    /// screenshot` works while `myapp screenshot` is refused.
315    ///
316    /// `browserUse` does not imply it: driving browser tabs reaches no native
317    /// window, and "open pages, don't touch my chrome" is a real choice.
318    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/// Runtime half of `splash:`. Images and colors are platform resources; only
334/// the minimum hold time is a runtime decision, and the upper bound is a
335/// framework constant that hosts deliberately cannot configure.
336#[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
343/// Default minimum hold, in milliseconds. Long enough that a fast first render
344/// does not flash the cover, short enough not to feel like a delay.
345pub 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
513/// Wall-clock origin for cold-start timing. First touched while the runtime
514/// loads `app.json`, which is early enough to stand in for process start.
515static STARTUP: std::sync::LazyLock<std::time::Instant> =
516    std::sync::LazyLock::new(std::time::Instant::now);
517
518/// Start the cold-start clock. Idempotent; call as early as possible.
519pub fn mark_startup() {
520    let _ = *STARTUP;
521}
522
523pub fn since_startup() -> std::time::Duration {
524    STARTUP.elapsed()
525}
526
527/// Whether this launch has a launch face. Its visible time starts at
528/// [`STARTUP`], because the OS frame already carries the same art.
529static SPLASH_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
530
531/// Note that this launch has a launch face, so the hold applies to it.
532///
533/// The face has been on screen since process start, not since this call: the
534/// OS frame carries the same art, so the user has been looking at it from the
535/// first frame the system composed. Charging the hold from when the runtime
536/// *learned* about it would hold a picture that is already several hundred
537/// milliseconds old — which is the launch feeling slow for no reason.
538///
539/// Idempotent: a warm relaunch that marks a second time must not restart the
540/// hold, or the face would outstay a launch the user is already past.
541pub fn mark_splash_visible() {
542    SPLASH_VISIBLE.store(true, std::sync::atomic::Ordering::Relaxed);
543}
544
545/// How long the launch face has been on screen, or `None` when this launch
546/// has no launch face at all.
547///
548/// `None` — a host with no splash configured, or a platform that never
549/// consults the splash (desktop, where the home-ready signal reveals the
550/// first window) — means there is nothing on screen for a hold to protect,
551/// and delaying the signal would only postpone real content.
552///
553/// Where the OS frame cannot carry the art (Android, whose system splash
554/// offers a colour and an icon slot and nothing else) the face really does
555/// begin at the app's own first draw, and that platform's overlay measures
556/// its own hold from there.
557pub 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
565/// How long the splash must stay up before a ready signal may dismiss it.
566pub 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        // Config used to reach the platforms only through this crate's own
573        // hold, which the dismissal timeout bounded anyway; now a platform can
574        // read the number and wait on it itself, so the documented upper bound
575        // has to be real here.
576        .min(SPLASH_HOLD_CAP_MS);
577    std::time::Duration::from_millis(u64::from(ms))
578}
579
580/// One-shot handoff between the host's campaign selector and home-first-ready.
581struct 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
611/// Hold a resolved campaign until the launch face is ready to hand over.
612/// Returns `false` when home already crossed that boundary and the late answer
613/// was dropped.
614pub 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
621/// Take the campaign, if one arrived in time. Taking rather than reading:
622/// the launch face hands over exactly once, and a campaign that missed that
623/// moment must not surface later over real content.
624pub 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
631/// The configured page floor for one appearance, as `#RRGGBB`.
632///
633/// `None` means the host did not declare one and the platform should keep
634/// using its own system background.
635pub 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
671/// Active environment version baked into the running build. Defaults to
672/// [`EnvVersion::Release`] before [`set_app_config`] is called and for any
673/// `app.json` produced before the envVersion field existed.
674pub 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
694/// The declared capability block, when this product shipped one.
695fn 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/// What the host *binary* was compiled with, recorded once at boot.
726///
727/// A capability is available only when the build carries it and the app
728/// declares it in `lingxia.yaml`; the declaration accessors above answer the
729/// second half. Defaults to all-false so a host that never records its build
730/// (tests, tools) reports nothing rather than over-promising.
731#[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
740/// Records the host build's capabilities. Idempotent; the first call wins.
741pub 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
749/// Whether the boot recorded the build yet. Lets a consumer assert it is not
750/// reading the all-false default before `set_host_build` ran.
751pub fn host_build_recorded() -> bool {
752    HOST_BUILD.get().is_some()
753}
754
755/// The one place each host capability is decided. `lx.supports()`, the FFI
756/// capability bitmask, and the optional `lx.*` members all read these, so they
757/// cannot drift apart.
758pub mod capability {
759    /// What the binary carries, independent of what an app declared. The
760    /// native SDKs' capability bitmask reports this.
761    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        /// Notifications are a platform fact rather than a build feature.
775        pub fn notifications() -> bool {
776            cfg!(any(target_os = "ios", target_env = "ohos"))
777        }
778    }
779
780    /// Managed browser tabs and the browser shell.
781    pub fn browser() -> bool {
782        build::browser() && super::browser_enabled()
783    }
784
785    /// Host notifications.
786    pub fn notifications() -> bool {
787        build::notifications() && super::notifications_enabled()
788    }
789
790    /// Driving this product's own windows and its command line. `computerUse`
791    /// already contains it, so it answers for both — the same rule the local
792    /// control surface enforces.
793    pub fn app_use() -> bool {
794        super::capabilities_config()
795            .map(|capabilities| capabilities.app_use_effective())
796            .unwrap_or(false)
797    }
798
799    /// Driving the whole machine: screenshots, synthetic input, the a11y tree.
800    pub fn computer_use() -> bool {
801        super::capabilities_config()
802            .map(|capabilities| capabilities.computer_use)
803            .unwrap_or(false)
804    }
805
806    /// Driving the in-app browser's tabs. `capabilities.browser` is the
807    /// prerequisite — there is nothing to drive without the engine — so both
808    /// have to be on. The declared flag alone would let `lx.supports` lie.
809    pub fn browser_use() -> bool {
810        browser()
811            && super::capabilities_config()
812                .map(|capabilities| capabilities.browser_use)
813                .unwrap_or(false)
814    }
815
816    /// Realtime capture tracks declared by the host.
817    pub fn media_capture() -> bool {
818        super::capabilities_config()
819            .map(|capabilities| capabilities.media_capture_enabled())
820            .unwrap_or(false)
821    }
822
823    /// The in-app browser's HTTP proxy. `capabilities.proxy` declares it and
824    /// `capabilities.browser` is its prerequisite — a proxy with nothing to
825    /// proxy is not a capability — so both have to be on.
826    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}