Skip to main content

lingxia_app_context/
lib.rs

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/// Host-app deployment environment baked into `app.json`.
21///
22/// This is the build-time axis (`dev` | `prod`): which server, package-id
23/// suffix, publish token, and self-update endpoint the host uses. It is
24/// **not** the lxapp publish channel (`release` | `draft`).
25/// Defined locally here (rather than imported) to keep `lingxia-app-context`
26/// free of additional crate dependencies; the JSON contract is what callers
27/// rely on.
28///
29/// Missing `env` is [`AppEnv::Prod`].
30#[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    /// Default lxapp channel for this host env: `dev` → `draft`,
47    /// `prod` → `release`. An open can pass an explicit channel to override;
48    /// the client does not forbid `draft` on a prod host.
49    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/// Opaque sRGB color used by the host theme wire format.
64#[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    /// The page floor: the colour an lxapp's own CSS paints its page with.
117    ///
118    /// The host declares it because native chrome has to agree with it in
119    /// places the page cannot reach — the strip a pull-to-refresh opens above
120    /// the page, the container a navigation transition slides views across —
121    /// and no platform can ask a WebView what colour its document is early
122    /// enough to paint the frame the user is already looking at. Unset falls
123    /// back to the platform's own system background, which is what every host
124    /// got before this existed.
125    #[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/// Light/dark as a choice: `auto` follows the system, `light` and `dark` pin
150/// it. The product's setting, an lxapp manifest's pin, and `theme`'s default
151/// all speak it.
152#[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    /// The product's appearance until the user picks one. A saved choice always
194    /// wins, and an lxapp that pins a scheme in its manifest keeps it. Absent
195    /// means `auto`: follow the system.
196    #[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        // `auto` is what an absent default already means.
209        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/// Static host configuration describing where a product's Settings affordance
226/// should navigate. Resolution and opening remain platform responsibilities.
227#[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    /// Locale-specific display names from `app.productNames`.
308    #[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    /// The environment this build was produced for. Defaults to [`AppEnv::Prod`]
324    /// when the field is missing.
325    #[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    /// Ed25519 public keys that may verify in-app update envelopes.
381    /// Embedded from host `lingxia.yaml` at build time; never from check-update.
382    /// Empty: `dev` still checks without verifying; `prod` skips.
383    #[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/// The `capabilities:` section, shared verbatim between the CLI (parsing
392/// `lingxia.yaml`, writing `app.json`) and the runtime (reading `app.json`) —
393/// one definition so a capability can never exist on one side only.
394/// `deny_unknown_fields` gives lingxia.yaml typo errors; the runtime always
395/// reads an app.json generated by the same CLI build, so it never sees fields
396/// this struct lacks.
397#[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    /// The product in-app browser, with its newtab / settings / downloads pages
403    /// and browser shell runtime. Opt-in and cross-platform.
404    #[serde(default)]
405    pub browser: bool,
406    #[serde(default)]
407    pub terminal: bool,
408    /// Opt-in HTTP proxy for the in-app browser (desktop). Requires browser.
409    #[serde(default)]
410    pub proxy: bool,
411    /// Allows the trusted home lxapp to launch and manage OS processes. The
412    /// lxapp must also declare the `process` security privilege.
413    #[serde(default)]
414    pub process: bool,
415    /// Unlocks `lx.app.autostart` (launch at system startup). macOS/Windows
416    /// only; enabling is always a runtime user decision, never automatic.
417    #[serde(default)]
418    pub autostart: bool,
419    /// Lets a command line or agent skill on the same machine drive this
420    /// product's own windows, and unlocks the product's command line. Desktop
421    /// only. The local socket it needs is derived, not declared: which IPC
422    /// carries this is plumbing, and a capability list says what a product can
423    /// do.
424    #[serde(default)]
425    pub app_use: bool,
426    /// Extends that to the whole machine: screenshots of any window, synthetic
427    /// input, the accessibility tree. Named for what the user is granting,
428    /// because they will be asked — macOS prompts for Accessibility and Screen
429    /// Recording, and the entry they see in System Settings is this product.
430    #[serde(default)]
431    pub computer_use: bool,
432    /// Extends it to the in-app browser. Requires `browser`.
433    #[serde(default)]
434    pub browser_use: bool,
435    /// Realtime visual / system-audio / microphone capture. Independent of
436    /// `computerUse`. Omit the key, or leave every track false, for no
437    /// provider, services, or entitlements.
438    #[serde(default, skip_serializing_if = "MediaCaptureConfig::is_empty")]
439    pub media_capture: MediaCaptureConfig,
440}
441
442/// Declared realtime-capture tracks. Each track is independently optional.
443#[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    /// Whether anything needs the local control socket. Derived rather than
466    /// declared: no product should have to know the transport's name to say
467    /// what it wants.
468    pub fn needs_control_socket(&self) -> bool {
469        self.app_use_effective() || self.browser_use
470    }
471
472    /// Whether this product's own windows may be driven.
473    ///
474    /// `computerUse` implies it. Not for symmetry — because it already
475    /// contains it: an agent that may screenshot any window and post input to
476    /// any window can reach this product's through the wider door. Requiring
477    /// both would add no protection and one failure mode, where a product
478    /// declares `computerUse`, forgets `appUse`, and `myapp computer
479    /// screenshot` works while `myapp screenshot` is refused.
480    ///
481    /// `browserUse` does not imply it: driving browser tabs reaches no native
482    /// window, and "open pages, don't touch my chrome" is a real choice.
483    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/// Runtime half of `splash:`. Images and colors are platform resources; only
499/// the minimum hold time is a runtime decision, and the upper bound is a
500/// framework constant that hosts deliberately cannot configure.
501#[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
508/// Default minimum hold, in milliseconds. Long enough that a fast first render
509/// does not flash the cover, short enough not to feel like a delay.
510pub 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    /// Resolve the display name for a locale: exact tag, then language,
655    /// then `productName`.
656    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
663/// Locale used to pick `productNames` translations. Typically the effective
664/// display language (`auto` → system, otherwise the in-app preference).
665pub 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
697/// Wall-clock origin for cold-start timing. First touched while the runtime
698/// loads `app.json`, which is early enough to stand in for process start.
699static STARTUP: std::sync::LazyLock<std::time::Instant> =
700    std::sync::LazyLock::new(std::time::Instant::now);
701
702/// Start the cold-start clock. Idempotent; call as early as possible.
703pub fn mark_startup() {
704    let _ = *STARTUP;
705}
706
707pub fn since_startup() -> std::time::Duration {
708    STARTUP.elapsed()
709}
710
711/// Whether this launch has a launch face. Its visible time starts at
712/// [`STARTUP`], because the OS frame already carries the same art.
713static SPLASH_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
714
715/// Note that this launch has a launch face, so the hold applies to it.
716///
717/// The face has been on screen since process start, not since this call: the
718/// OS frame carries the same art, so the user has been looking at it from the
719/// first frame the system composed. Charging the hold from when the runtime
720/// *learned* about it would hold a picture that is already several hundred
721/// milliseconds old — which is the launch feeling slow for no reason.
722///
723/// Idempotent: a warm relaunch that marks a second time must not restart the
724/// hold, or the face would outstay a launch the user is already past.
725pub fn mark_splash_visible() {
726    SPLASH_VISIBLE.store(true, std::sync::atomic::Ordering::Relaxed);
727}
728
729/// How long the launch face has been on screen, or `None` when this launch
730/// has no launch face at all.
731///
732/// `None` — a host with no splash configured, or a platform that never
733/// consults the splash (desktop, where the home-ready signal reveals the
734/// first window) — means there is nothing on screen for a hold to protect,
735/// and delaying the signal would only postpone real content.
736///
737/// Where the OS frame cannot carry the art (Android, whose system splash
738/// offers a colour and an icon slot and nothing else) the face really does
739/// begin at the app's own first draw, and that platform's overlay measures
740/// its own hold from there.
741pub 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
749/// How long the splash must stay up before a ready signal may dismiss it.
750pub 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        // Config used to reach the platforms only through this crate's own
757        // hold, which the dismissal timeout bounded anyway; now a platform can
758        // read the number and wait on it itself, so the documented upper bound
759        // has to be real here.
760        .min(SPLASH_HOLD_CAP_MS);
761    std::time::Duration::from_millis(u64::from(ms))
762}
763
764/// One-shot handoff between the host's campaign selector and home-first-ready.
765struct 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
795/// Hold a resolved campaign until the launch face is ready to hand over.
796/// Returns `false` when home already crossed that boundary and the late answer
797/// was dropped.
798pub 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
805/// Take the campaign, if one arrived in time. Taking rather than reading:
806/// the launch face hands over exactly once, and a campaign that missed that
807/// moment must not surface later over real content.
808pub 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
815/// The configured page floor for one appearance, as `#RRGGBB`.
816///
817/// `None` means the host did not declare one and the platform should keep
818/// using its own system background.
819pub 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
924/// Active host env baked into the running build. Defaults to [`AppEnv::Prod`]
925/// before [`set_app_config`] is called and when `app.json` omits `env`.
926pub 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
946/// The declared capability block, when this product shipped one.
947fn 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/// What the host *binary* was compiled with, recorded once at boot.
978///
979/// A capability is available only when the build carries it and the app
980/// declares it in `lingxia.yaml`; the declaration accessors above answer the
981/// second half. Defaults to all-false so a host that never records its build
982/// (tests, tools) reports nothing rather than over-promising.
983#[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
992/// Records the host build's capabilities. Idempotent; the first call wins.
993pub 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
1001/// Whether the boot recorded the build yet. Lets a consumer assert it is not
1002/// reading the all-false default before `set_host_build` ran.
1003pub fn host_build_recorded() -> bool {
1004    HOST_BUILD.get().is_some()
1005}
1006
1007/// The one place each host capability is decided. `lx.supports()`, the FFI
1008/// capability bitmask, and the optional `lx.*` members all read these, so they
1009/// cannot drift apart.
1010pub mod capability {
1011    /// What the binary carries, independent of what an app declared. The
1012    /// native SDKs' capability bitmask reports this.
1013    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        /// Notifications are a platform fact rather than a build feature.
1027        pub fn notifications() -> bool {
1028            cfg!(any(target_os = "ios", target_env = "ohos"))
1029        }
1030    }
1031
1032    /// Managed browser tabs and the browser shell.
1033    pub fn browser() -> bool {
1034        build::browser() && super::browser_enabled()
1035    }
1036
1037    /// Host notifications.
1038    pub fn notifications() -> bool {
1039        build::notifications() && super::notifications_enabled()
1040    }
1041
1042    /// Driving this product's own windows and its command line. `computerUse`
1043    /// already contains it, so it answers for both — the same rule the local
1044    /// control surface enforces.
1045    pub fn app_use() -> bool {
1046        super::capabilities_config()
1047            .map(|capabilities| capabilities.app_use_effective())
1048            .unwrap_or(false)
1049    }
1050
1051    /// Driving the whole machine: screenshots, synthetic input, the a11y tree.
1052    pub fn computer_use() -> bool {
1053        super::capabilities_config()
1054            .map(|capabilities| capabilities.computer_use)
1055            .unwrap_or(false)
1056    }
1057
1058    /// Driving the in-app browser's tabs. `capabilities.browser` is the
1059    /// prerequisite — there is nothing to drive without the engine — so both
1060    /// have to be on. The declared flag alone would let `lx.supports` lie.
1061    pub fn browser_use() -> bool {
1062        browser()
1063            && super::capabilities_config()
1064                .map(|capabilities| capabilities.browser_use)
1065                .unwrap_or(false)
1066    }
1067
1068    /// Realtime capture tracks declared by the host.
1069    pub fn media_capture() -> bool {
1070        super::capabilities_config()
1071            .map(|capabilities| capabilities.media_capture_enabled())
1072            .unwrap_or(false)
1073    }
1074
1075    /// The in-app browser's HTTP proxy. `capabilities.proxy` declares it and
1076    /// `capabilities.browser` is its prerequisite — a proxy with nothing to
1077    /// proxy is not a capability — so both have to be on.
1078    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}