use semver::Version;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use thiserror::Error;
static APP_CONFIG: OnceLock<AppConfig> = OnceLock::new();
const APP_STATE_DIR: &str = "app_state";
#[derive(Debug, Error)]
pub enum AppContextError {
#[error("invalid app.json: {0}")]
InvalidJson(String),
#[error("invalid app config: {0}")]
InvalidConfig(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EnvVersion {
#[default]
Release,
Preview,
Developer,
}
impl EnvVersion {
pub fn as_str(self) -> &'static str {
match self {
Self::Release => "release",
Self::Preview => "preview",
Self::Developer => "developer",
}
}
}
impl std::fmt::Display for EnvVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct AppConfig {
#[serde(rename = "productName")]
pub product_name: String,
#[serde(rename = "productVersion")]
pub product_version: String,
#[serde(rename = "lingxiaId", default)]
pub lingxia_id: Option<String>,
#[serde(rename = "lingxiaServer", default)]
pub lingxia_server: Option<String>,
#[serde(rename = "envVersion", default)]
pub env_version: EnvVersion,
#[serde(rename = "homeAppId")]
pub home_app_id: String,
#[serde(rename = "homeAppVersion")]
pub home_app_version: String,
#[serde(rename = "cacheMaxSizeMB", default = "default_cache_max_size_mb")]
pub cache_max_size_mb: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<StorageConfig>,
#[serde(rename = "devWsUrl", default, skip_serializing_if = "Option::is_none")]
pub dev_ws_url: Option<String>,
#[serde(rename = "appLinks", default, skip_serializing_if = "Option::is_none")]
pub app_links: Option<AppLinksConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<CapabilitiesConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub panels: Option<PanelsConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct CapabilitiesConfig {
#[serde(default)]
pub notifications: bool,
#[serde(default)]
pub terminal: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct AppLinksConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hosts: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageConfig {
#[serde(rename = "tempMaxSizeMB")]
#[serde(default = "default_temp_max_size_mb")]
pub temp_max_size_mb: u64,
#[serde(rename = "cacheMaxSizeMB")]
#[serde(default = "default_cache_max_size_mb")]
pub cache_max_size_mb: u64,
#[serde(rename = "dataMaxSizeMB")]
#[serde(default = "default_data_max_size_mb")]
pub data_max_size_mb: u64,
#[serde(rename = "appStorageMaxSizeMB")]
#[serde(default = "default_app_storage_max_size_mb")]
pub app_storage_max_size_mb: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct PanelsConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub items: Vec<PanelItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PanelPosition {
Left,
Right,
Bottom,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct PanelItem {
pub id: String,
pub label: String,
pub icon: String,
#[serde(default = "default_panel_position")]
pub position: PanelPosition,
pub content: PanelContent,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct PanelContent {
#[serde(rename = "appId")]
pub app_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
fn default_cache_max_size_mb() -> u64 {
2048
}
fn default_temp_max_size_mb() -> u64 {
1024
}
fn default_data_max_size_mb() -> u64 {
4096
}
fn default_app_storage_max_size_mb() -> u64 {
16384
}
fn default_panel_position() -> PanelPosition {
PanelPosition::Right
}
impl AppConfig {
pub fn parse_and_validate(content: &str) -> Result<Self, AppContextError> {
let config: Self = serde_json::from_str(content).map_err(|e| {
AppContextError::InvalidJson(format!("Failed to parse app.json: {}", e))
})?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), AppContextError> {
if self.product_name.is_empty() {
return Err(AppContextError::InvalidConfig(
"productName is mandatory and cannot be empty".to_string(),
));
}
if self.product_version.is_empty() {
return Err(AppContextError::InvalidConfig(
"productVersion is mandatory and cannot be empty".to_string(),
));
}
Version::parse(&self.product_version).map_err(|_| {
AppContextError::InvalidConfig(
"productVersion must be a semantic version (major.minor.patch)".to_string(),
)
})?;
if self.home_app_id.is_empty() {
return Err(AppContextError::InvalidConfig(
"homeAppId is mandatory and cannot be empty".to_string(),
));
}
if self.home_app_version.is_empty() {
return Err(AppContextError::InvalidConfig(
"homeAppVersion is mandatory and cannot be empty".to_string(),
));
}
Version::parse(&self.home_app_version).map_err(|_| {
AppContextError::InvalidConfig(
"homeAppVersion must be a semantic version (major.minor.patch)".to_string(),
)
})?;
validate_panels(self.panels.as_ref())
}
}
pub fn set_app_config(config: AppConfig) -> Result<(), AppContextError> {
if let Some(existing) = APP_CONFIG.get() {
if existing == &config {
return Ok(());
}
return Err(AppContextError::InvalidConfig(
"app config is already initialized with different values".to_string(),
));
}
APP_CONFIG
.set(config)
.map_err(|_| {
AppContextError::InvalidConfig(
"app config was initialized concurrently with different values".to_string(),
)
})
.map(|_| ())
}
pub fn app_config() -> Option<&'static AppConfig> {
APP_CONFIG.get()
}
pub fn product_name() -> Option<&'static str> {
APP_CONFIG.get().map(|c| c.product_name.as_str())
}
pub fn home_app_id() -> Option<&'static str> {
APP_CONFIG.get().map(|c| c.home_app_id.as_str())
}
pub fn home_app_version() -> Option<&'static str> {
APP_CONFIG.get().map(|c| c.home_app_version.as_str())
}
pub fn product_version() -> Option<&'static str> {
APP_CONFIG.get().map(|c| c.product_version.as_str())
}
pub fn lingxia_id() -> Option<&'static str> {
APP_CONFIG
.get()
.and_then(|c| c.lingxia_id.as_deref())
.filter(|s| !s.is_empty())
}
pub fn env_version() -> EnvVersion {
APP_CONFIG.get().map(|c| c.env_version).unwrap_or_default()
}
pub fn notifications_enabled() -> bool {
APP_CONFIG
.get()
.and_then(|c| c.capabilities.as_ref())
.map(|capabilities| capabilities.notifications)
.unwrap_or(false)
}
pub fn terminal_enabled() -> bool {
APP_CONFIG
.get()
.and_then(|c| c.capabilities.as_ref())
.map(|capabilities| capabilities.terminal)
.unwrap_or(false)
}
pub fn temp_max_size_bytes() -> u64 {
const MIB: u64 = 1024 * 1024;
APP_CONFIG
.get()
.and_then(|c| c.storage.as_ref().map(|storage| storage.temp_max_size_mb))
.unwrap_or_else(default_temp_max_size_mb)
.saturating_mul(MIB)
}
pub fn cache_max_size_bytes() -> u64 {
const MIB: u64 = 1024 * 1024;
APP_CONFIG
.get()
.map(|c| {
c.storage
.as_ref()
.map(|storage| storage.cache_max_size_mb)
.unwrap_or(c.cache_max_size_mb)
})
.unwrap_or_else(default_cache_max_size_mb)
.saturating_mul(MIB)
}
pub fn data_max_size_bytes() -> u64 {
const MIB: u64 = 1024 * 1024;
APP_CONFIG
.get()
.and_then(|c| c.storage.as_ref().map(|storage| storage.data_max_size_mb))
.unwrap_or_else(default_data_max_size_mb)
.saturating_mul(MIB)
}
pub fn app_storage_max_size_bytes() -> u64 {
const MIB: u64 = 1024 * 1024;
APP_CONFIG
.get()
.and_then(|c| {
c.storage
.as_ref()
.map(|storage| storage.app_storage_max_size_mb)
})
.unwrap_or_else(default_app_storage_max_size_mb)
.saturating_mul(MIB)
}
pub fn app_state_dir(app_data_dir: &Path) -> PathBuf {
app_data_dir.join(APP_STATE_DIR)
}
pub fn app_state_file(app_data_dir: &Path, name: &str) -> PathBuf {
app_state_dir(app_data_dir).join(name)
}
fn validate_panels(panels: Option<&PanelsConfig>) -> Result<(), AppContextError> {
let Some(panels) = panels else {
return Ok(());
};
let mut ids = HashSet::new();
let mut positions = HashSet::new();
let mut app_ids = HashSet::new();
for item in &panels.items {
if item.id.is_empty() {
return Err(AppContextError::InvalidConfig(
"panels.items[].id cannot be empty".to_string(),
));
}
if item.label.is_empty() {
return Err(AppContextError::InvalidConfig(format!(
"panel '{}' label cannot be empty",
item.id
)));
}
if item.content.app_id.is_empty() {
return Err(AppContextError::InvalidConfig(format!(
"panel '{}' content.appId cannot be empty",
item.id
)));
}
if !ids.insert(item.id.clone()) {
return Err(AppContextError::InvalidConfig(format!(
"duplicate panel id '{}'",
item.id
)));
}
if !positions.insert(item.position) {
return Err(AppContextError::InvalidConfig(format!(
"only one panel is supported at position '{}'",
panel_position_name(item.position)
)));
}
if !app_ids.insert(item.content.app_id.clone()) {
return Err(AppContextError::InvalidConfig(format!(
"panel appId '{}' must be unique",
item.content.app_id
)));
}
}
Ok(())
}
fn panel_position_name(position: PanelPosition) -> &'static str {
match position {
PanelPosition::Left => "left",
PanelPosition::Right => "right",
PanelPosition::Bottom => "bottom",
}
}
#[cfg(test)]
mod tests {
use super::{AppConfig, AppContextError, set_app_config};
fn test_config(product_name: &str) -> AppConfig {
AppConfig {
product_name: product_name.to_string(),
product_version: "1.0.0".to_string(),
lingxia_id: Some("lingxia".to_string()),
lingxia_server: None,
env_version: super::EnvVersion::Release,
home_app_id: "home".to_string(),
home_app_version: "1.0.0".to_string(),
cache_max_size_mb: 1024,
storage: None,
dev_ws_url: None,
app_links: None,
capabilities: None,
panels: None,
}
}
#[test]
fn set_app_config_rejects_mismatched_value_after_initialization() {
let cfg = test_config("LingXia");
assert!(set_app_config(cfg.clone()).is_ok());
assert!(set_app_config(cfg).is_ok());
let err = set_app_config(test_config("Other")).unwrap_err();
assert!(matches!(err, AppContextError::InvalidConfig(_)));
}
}