use crate::archive;
use crate::error::LxAppError;
use crate::lxapp::config::LxPlugin;
use crate::lxapp::uri as lx_uri;
use crate::lxapp::{LINGXIA_DIR, PLUGINS_DIR};
use crate::provider::provider_error_to_lxapp_error;
use crate::warn;
use dashmap::DashMap;
use lingxia_platform::Platform;
use lingxia_platform::traits::app_runtime::AppRuntime;
use lingxia_update::UpdateTarget;
use rong_rt::download::{self as service_executor};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use tokio::sync::watch;
fn plugin_key(name: &str, version: &str) -> String {
format!("{}@{}", name, version)
}
#[derive(Clone, Debug, PartialEq)]
pub enum PluginDownloadState {
Downloading,
Completed,
Failed(String),
}
struct PluginDownloadTracker {
downloads: DashMap<String, (PluginDownloadState, watch::Sender<PluginDownloadState>)>,
}
impl PluginDownloadTracker {
fn new() -> Self {
Self {
downloads: DashMap::new(),
}
}
fn try_start_download(&self, key: &str) -> Option<watch::Receiver<PluginDownloadState>> {
if self.downloads.contains_key(key) {
return None;
}
let (tx, rx) = watch::channel(PluginDownloadState::Downloading);
self.downloads
.insert(key.to_string(), (PluginDownloadState::Downloading, tx));
Some(rx)
}
fn mark_completed(&self, key: &str) {
if let Some(mut entry) = self.downloads.get_mut(key) {
entry.0 = PluginDownloadState::Completed;
let _ = entry.1.send(PluginDownloadState::Completed);
}
self.downloads.remove(key);
}
fn mark_failed(&self, key: &str, error: String) {
if let Some(mut entry) = self.downloads.get_mut(key) {
let state = PluginDownloadState::Failed(error.clone());
entry.0 = state.clone();
let _ = entry.1.send(state);
}
self.downloads.remove(key);
}
fn get_download_receiver(&self, key: &str) -> Option<watch::Receiver<PluginDownloadState>> {
self.downloads.get(key).map(|e| e.1.subscribe())
}
}
static DOWNLOAD_TRACKER: OnceLock<PluginDownloadTracker> = OnceLock::new();
fn get_tracker() -> &'static PluginDownloadTracker {
DOWNLOAD_TRACKER.get_or_init(PluginDownloadTracker::new)
}
pub fn wait_for_download(
plugin_name: &str,
version: &str,
) -> Option<watch::Receiver<PluginDownloadState>> {
get_tracker().get_download_receiver(&plugin_key(plugin_name, version))
}
pub fn parse_plugin_url(url: &str) -> Option<(String, String)> {
lx_uri::parse_plugin_url(url)
}
pub fn parse_plugin_page_path(path: &str) -> Option<(String, String)> {
lx_uri::parse_plugin_page_path(path)
}
pub fn build_plugin_page_path(plugin_name: &str, page_path: &str) -> String {
lx_uri::build_plugin_page_path(plugin_name, page_path)
}
pub fn resolve_plugin_page_path(config: &LxPlugin, page_path: &str) -> String {
if let Some(internal_path) = config.pages.get(page_path) {
internal_path.clone()
} else {
page_path.to_string()
}
}
pub fn get_plugin_logic_js(
runtime: &Arc<Platform>,
plugin_name: &str,
config: &LxPlugin,
) -> Option<PathBuf> {
let plugin_dir = get_plugin_dir(runtime, plugin_name, &config.version);
let entry = plugin_entry_js(config);
let logic_path = plugin_dir.join(&entry);
if logic_path.exists() {
Some(logic_path)
} else {
None
}
}
fn plugin_entry_js(config: &LxPlugin) -> String {
let entry = config.main.trim();
if entry.is_empty() || !is_safe_plugin_entry(entry) {
"logic.js".to_string()
} else {
entry.to_string()
}
}
fn is_safe_plugin_entry(entry: &str) -> bool {
if entry.contains('\\') {
return false;
}
std::path::Path::new(entry)
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
}
pub fn get_plugins_dir(runtime: &Arc<Platform>) -> PathBuf {
runtime.app_data_dir().join(LINGXIA_DIR).join(PLUGINS_DIR)
}
pub fn get_plugin_dir(runtime: &Arc<Platform>, plugin_name: &str, version: &str) -> PathBuf {
get_plugins_dir(runtime).join(plugin_name).join(version)
}
pub fn load_plugin_manifest_pages(
runtime: &Arc<Platform>,
plugin_name: &str,
config: &LxPlugin,
) -> Option<std::collections::BTreeMap<String, String>> {
let plugin_dir = get_plugin_dir(runtime, plugin_name, &config.version);
let manifest_path = plugin_dir.join("lxplugin.json");
if !manifest_path.exists() {
return None;
}
match std::fs::read_to_string(&manifest_path) {
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json) => {
if let Some(pages) = json.get("pages").and_then(|p| p.as_array()) {
let mut result = std::collections::BTreeMap::new();
for page in pages {
let Some(name) = page.get("name").and_then(|value| value.as_str()) else {
continue;
};
let Some(path) = page.get("path").and_then(|value| value.as_str()) else {
continue;
};
result.insert(name.to_string(), path.to_string());
}
return Some(result);
}
}
Err(e) => {
warn!(
"Failed to parse plugin manifest {}: {}",
manifest_path.display(),
e
);
}
},
Err(e) => {
warn!(
"Failed to read plugin manifest {}: {}",
manifest_path.display(),
e
);
}
}
None
}
pub fn resolve_plugin_page(
runtime: &Arc<Platform>,
plugins: &BTreeMap<String, LxPlugin>,
plugin_name: &str,
page_path: &str,
) -> Result<String, LxAppError> {
let plugin_cfg = plugins
.get(plugin_name)
.ok_or_else(|| LxAppError::PluginNotConfigured(plugin_name.to_string()))?;
if !plugin_cfg.pages.is_empty() {
return Ok(resolve_plugin_page_path(plugin_cfg, page_path));
}
Ok(load_plugin_manifest_pages(runtime, plugin_name, plugin_cfg)
.and_then(|pages| pages.get(page_path).cloned())
.unwrap_or_else(|| page_path.to_string()))
}
pub fn resolve_plugin_resource_path(
runtime: &Arc<Platform>,
plugins: &BTreeMap<String, LxPlugin>,
plugin_name: &str,
relative_path: &str,
) -> Result<PathBuf, LxAppError> {
let plugin_cfg = plugins
.get(plugin_name)
.ok_or_else(|| LxAppError::PluginNotConfigured(plugin_name.to_string()))?;
let plugin_dir = get_plugin_dir(runtime, plugin_name, &plugin_cfg.version);
if relative_path.is_empty() {
if plugin_dir.exists() {
return Ok(plugin_dir);
}
return Err(LxAppError::ResourceNotFound(format!(
"Plugin directory not found: {}",
plugin_name
)));
}
let full_path = plugin_dir.join(relative_path);
if let Ok(canonical) = full_path.canonicalize() {
let plugin_dir_canonical = plugin_dir
.canonicalize()
.unwrap_or_else(|_| plugin_dir.clone());
if canonical.starts_with(&plugin_dir_canonical) {
return Ok(canonical);
}
}
if full_path.exists() {
return Ok(full_path);
}
Err(LxAppError::ResourceNotFound(format!(
"Plugin resource not found: plugin/{}/{}",
plugin_name, relative_path
)))
}
pub fn resolve_plugin_resource_path_from_internal_path(
runtime: &Arc<Platform>,
plugins: &BTreeMap<String, LxPlugin>,
path: &str,
) -> Result<Option<PathBuf>, LxAppError> {
let Some((plugin_name, rel_path)) = parse_plugin_page_path(path) else {
return Ok(None);
};
resolve_plugin_resource_path(runtime, plugins, &plugin_name, &rel_path).map(Some)
}
pub async fn download_and_install(
runtime: Arc<Platform>,
plugin_name: &str,
config: &LxPlugin,
) -> Result<PathBuf, LxAppError> {
let version = &config.version;
let key = plugin_key(plugin_name, version);
let install_dir = get_plugin_dir(&runtime, plugin_name, version);
if install_dir.exists() {
return Ok(install_dir);
}
let maybe_rx = get_tracker().try_start_download(&key);
let is_initiator = maybe_rx.is_some();
let mut rx = maybe_rx.or_else(|| wait_for_download(plugin_name, version));
if !is_initiator {
if let Some(mut rx) = rx.take() {
while rx.changed().await.is_ok() {
match &*rx.borrow() {
PluginDownloadState::Completed => {
return Ok(get_plugin_dir(&runtime, plugin_name, version));
}
PluginDownloadState::Failed(err) => {
return Err(LxAppError::PluginDownloadFailed(err.clone()));
}
PluginDownloadState::Downloading => continue,
}
}
return Err(LxAppError::PluginDownloadFailed(
"Plugin download tracking closed unexpectedly".to_string(),
));
} else {
return Err(LxAppError::PluginDownloadFailed(
"Plugin download already in progress".to_string(),
));
}
}
let result = download_and_install_internal(runtime.clone(), plugin_name, version, config).await;
match &result {
Ok(_) => get_tracker().mark_completed(&key),
Err(e) => get_tracker().mark_failed(&key, e.to_string()),
}
result
}
async fn download_and_install_internal(
runtime: Arc<Platform>,
plugin_name: &str,
version: &str,
config: &LxPlugin,
) -> Result<PathBuf, LxAppError> {
let plugin_id = &config.lx_plugin_id;
let required_version = &config.version;
let provider = crate::get_provider();
let target = UpdateTarget::Plugin {
id: plugin_id.to_string(),
version: required_version.to_string(),
};
let package = provider
.check_update(target)
.await
.map_err(|e| provider_error_to_lxapp_error(&e))?
.ok_or_else(|| {
LxAppError::IoError(format!(
"Plugin {} (lxPluginId: {}) not found on server",
plugin_name, plugin_id
))
})?;
let plugins_dir = get_plugins_dir(&runtime);
let download_dir = plugins_dir.join("download");
fs::create_dir_all(&download_dir)?;
let archive_path = download_dir.join(format!("{}-{}.tar.zst", plugin_name, version));
if archive_path.exists() {
let _ = fs::remove_file(&archive_path);
}
let receiver =
service_executor::request_download(package.url.clone(), archive_path.clone(), None, None)
.map_err(|e| LxAppError::IoError(format!("Failed to start plugin download: {}", e)))?;
match receiver.await {
Ok(Ok(())) => {
if !package.checksum_sha256.is_empty() {
archive::verify_sha256(&archive_path, &package.checksum_sha256)?;
}
}
Ok(Err(err)) => {
let _ = fs::remove_file(&archive_path);
return Err(LxAppError::IoError(format!(
"Plugin download failed: {}",
err
)));
}
Err(_) => {
let _ = fs::remove_file(&archive_path);
return Err(LxAppError::IoError(
"Plugin download task cancelled".to_string(),
));
}
}
let install_path = install_plugin_archive(&runtime, plugin_name, version, &archive_path)?;
let _ = fs::remove_file(&archive_path);
Ok(install_path)
}
fn install_plugin_archive(
runtime: &Arc<Platform>,
plugin_name: &str,
version: &str,
archive_path: &Path,
) -> Result<PathBuf, LxAppError> {
let destination = get_plugin_dir(runtime, plugin_name, version);
archive::extract_tar_zst(archive_path, &destination)?;
Ok(destination)
}