use lingxia_update::{Channel, SemanticVersion};
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use super::version::Version;
use crate::LxAppError;
const INSTALLED_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("installed");
const DOWNLOADED_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("downloaded");
const REGISTRY_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("registry");
static DATABASE: OnceLock<Arc<Database>> = OnceLock::new();
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LxAppRecord {
pub lxappid: String,
pub release_type: Channel,
pub version: SemanticVersion,
pub fingermark: String,
pub install_path: String,
pub last_open_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum_sha256: Option<String>,
}
impl LxAppRecord {
pub fn new(
lxappid: &str,
release_type: Channel,
version: SemanticVersion,
fingermark: String,
install_path: String,
last_open_at: i64,
) -> Self {
Self {
lxappid: lxappid.to_string(),
release_type,
version,
fingermark,
install_path,
last_open_at,
checksum_sha256: None,
}
}
pub fn version_string(&self) -> String {
self.version.to_version_string()
}
}
pub(crate) fn init(db_path: PathBuf) -> Result<(), LxAppError> {
if DATABASE.get().is_some() {
return Ok(());
}
if let Some(parent) = db_path.parent() {
fs::create_dir_all(parent)?;
}
let db = if db_path.exists() {
Database::open(&db_path).map_err(|e| metadata_error("open database", e))?
} else {
Database::create(&db_path).map_err(|e| metadata_error("create database", e))?
};
let write_txn = db
.begin_write()
.map_err(|e| metadata_error("begin write transaction", e))?;
{
let _installed = write_txn
.open_table(INSTALLED_TABLE)
.map_err(|e| metadata_error("open installed table", e))?;
let _downloaded = write_txn
.open_table(DOWNLOADED_TABLE)
.map_err(|e| metadata_error("open downloaded table", e))?;
let _registry = write_txn
.open_table(REGISTRY_TABLE)
.map_err(|e| metadata_error("open registry table", e))?;
}
write_txn
.commit()
.map_err(|e| metadata_error("commit table creation", e))?;
let _ = DATABASE.set(Arc::new(db));
Ok(())
}
pub(crate) fn get(lxappid: &str, release_type: Channel) -> Result<Option<LxAppRecord>, LxAppError> {
let key = key_for(lxappid, release_type);
let db = database()?;
let txn = db
.begin_read()
.map_err(|e| metadata_error("begin read transaction", e))?;
let table = txn
.open_table(INSTALLED_TABLE)
.map_err(|e| metadata_error("open installed table", e))?;
if let Some(value) = table
.get(key.as_str())
.map_err(|e| metadata_error("read record", e))?
{
let record: LxAppRecord = serde_json::from_slice(value.value())?;
Ok(Some(record))
} else {
Ok(None)
}
}
pub(crate) fn upsert(record: &LxAppRecord) -> Result<(), LxAppError> {
let key = key_for(&record.lxappid, record.release_type);
let db = database()?;
let txn = db
.begin_write()
.map_err(|e| metadata_error("begin write transaction", e))?;
{
let mut table = txn
.open_table(INSTALLED_TABLE)
.map_err(|e| metadata_error("open installed table", e))?;
let serialized = serde_json::to_vec(record)?;
table
.insert(key.as_str(), serialized.as_slice())
.map_err(|e| metadata_error("write installed record", e))?;
}
txn.commit()
.map_err(|e| metadata_error("commit installed write", e))?;
Ok(())
}
pub(crate) fn remove_all(lxappid: &str) -> Result<(), LxAppError> {
let prefix = format!("{}::", lxappid);
let db = database()?;
let txn = db
.begin_write()
.map_err(|e| metadata_error("begin write transaction", e))?;
{
let mut table = txn
.open_table(INSTALLED_TABLE)
.map_err(|e| metadata_error("open installed table", e))?;
let mut keys_to_remove = Vec::new();
let iter = table
.iter()
.map_err(|e| metadata_error("iterate installed records", e))?;
for entry in iter {
let (key, _) = entry.map_err(|e| metadata_error("read installed record", e))?;
let key_value = key.value();
if key_value.starts_with(&prefix) {
keys_to_remove.push(key_value.to_string());
}
}
for key in keys_to_remove {
table
.remove(key.as_str())
.map_err(|e| metadata_error("delete installed record", e))?;
}
}
txn.commit()
.map_err(|e| metadata_error("commit installed delete", e))?;
Ok(())
}
pub(crate) fn remove(lxappid: &str, release_type: Channel) -> Result<(), LxAppError> {
let key = key_for(lxappid, release_type);
let db = database()?;
let txn = db
.begin_write()
.map_err(|e| metadata_error("begin write transaction", e))?;
{
let mut table = txn
.open_table(INSTALLED_TABLE)
.map_err(|e| metadata_error("open installed table", e))?;
table
.remove(key.as_str())
.map_err(|e| metadata_error("delete installed record", e))?;
}
txn.commit()
.map_err(|e| metadata_error("commit installed delete", e))?;
Ok(())
}
fn key_for(lxappid: &str, release_type: Channel) -> String {
format!("{}::{}", lxappid, release_type.as_str())
}
pub(crate) fn database() -> Result<Arc<Database>, LxAppError> {
DATABASE
.get()
.cloned()
.ok_or_else(|| LxAppError::Runtime("metadata database not initialized".to_string()))
}
pub(crate) fn metadata_error(action: &str, err: impl fmt::Display) -> LxAppError {
LxAppError::Runtime(format!("metadata database {} failed: {}", action, err))
}
pub(crate) fn touch_last_open(
lxappid: &str,
release_type: Channel,
ts: i64,
) -> Result<(), LxAppError> {
if let Some(mut record) = get(lxappid, release_type)? {
record.last_open_at = ts;
upsert(&record)?;
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PendingUpdateRecord {
pub lxappid: String,
pub release_type: Channel,
pub version: SemanticVersion,
pub zip_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksum_sha256: Option<String>,
}
pub(crate) fn downloaded_get(
lxappid: &str,
release_type: Channel,
) -> Result<Option<PendingUpdateRecord>, LxAppError> {
let key = key_for(lxappid, release_type);
let db = database()?;
let txn = db
.begin_read()
.map_err(|e| metadata_error("begin read transaction", e))?;
let table = txn
.open_table(DOWNLOADED_TABLE)
.map_err(|e| metadata_error("open downloaded table", e))?;
if let Some(value) = table
.get(key.as_str())
.map_err(|e| metadata_error("read downloaded record", e))?
{
let record: PendingUpdateRecord = serde_json::from_slice(value.value())?;
Ok(Some(record))
} else {
Ok(None)
}
}
pub(crate) fn downloaded_remove(lxappid: &str, release_type: Channel) -> Result<(), LxAppError> {
let record = downloaded_get(lxappid, release_type)?;
if let Some(rec) = record {
let archive_path = std::path::PathBuf::from(&rec.zip_path);
if archive_path.exists()
&& let Err(e) = std::fs::remove_file(&archive_path)
{
crate::warn!(
"Failed to remove archive file at {}: {}. Disk space may be wasted.",
archive_path.display(),
e
);
}
}
let key = key_for(lxappid, release_type);
let db = database()?;
let txn = db
.begin_write()
.map_err(|e| metadata_error("begin write transaction", e))?;
{
let mut table = txn
.open_table(DOWNLOADED_TABLE)
.map_err(|e| metadata_error("open downloaded table", e))?;
table
.remove(key.as_str())
.map_err(|e| metadata_error("delete downloaded record", e))?;
}
txn.commit()
.map_err(|e| metadata_error("commit downloaded delete", e))?;
Ok(())
}
pub(crate) fn downloaded_upsert(
lxappid: &str,
release_type: Channel,
version: &str,
zip_path: &std::path::Path,
checksum_sha256: Option<&str>,
) -> Result<(), LxAppError> {
let parsed_version = Version::parse(version).map_err(|_| {
LxAppError::InvalidParameter(format!("Invalid semantic version: {}", version))
})?;
let record = PendingUpdateRecord {
lxappid: lxappid.to_string(),
release_type,
version: SemanticVersion::from_version(&parsed_version),
zip_path: zip_path.to_string_lossy().to_string(),
checksum_sha256: checksum_sha256
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase()),
};
let key = key_for(lxappid, release_type);
let db = database()?;
let txn = db
.begin_write()
.map_err(|e| metadata_error("begin write transaction", e))?;
{
let mut table = txn
.open_table(DOWNLOADED_TABLE)
.map_err(|e| metadata_error("open downloaded table", e))?;
let serialized = serde_json::to_vec(&record)?;
table
.insert(key.as_str(), serialized.as_slice())
.map_err(|e| metadata_error("write downloaded record", e))?;
}
txn.commit()
.map_err(|e| metadata_error("commit downloaded write", e))?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RegistryRecord {
pub appid: String,
pub name: Option<String>,
pub description: Option<String>,
pub icon_url: Option<String>,
pub icon_file: Option<String>,
pub status: String,
#[serde(default)]
pub grants: BTreeMap<String, StoredGrant>,
pub fetched_at: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(crate) struct StoredGrant {
#[serde(default)]
pub permissions: Option<StoredPermissions>,
pub fetched_at: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(crate) struct StoredPermissions {
#[serde(default)]
pub domains: Option<Vec<String>>,
#[serde(default)]
pub privileges: Option<Vec<String>>,
}
pub(crate) fn registry_get(appid: &str) -> Result<Option<RegistryRecord>, LxAppError> {
let db = database()?;
let txn = db
.begin_read()
.map_err(|e| metadata_error("begin read transaction", e))?;
let table = txn
.open_table(REGISTRY_TABLE)
.map_err(|e| metadata_error("open registry table", e))?;
let Some(value) = table
.get(appid)
.map_err(|e| metadata_error("read registry record", e))?
else {
return Ok(None);
};
Ok(Some(serde_json::from_slice(value.value())?))
}
pub(crate) fn registry_upsert(record: &RegistryRecord) -> Result<(), LxAppError> {
let key = record.appid.as_str();
let db = database()?;
let txn = db
.begin_write()
.map_err(|e| metadata_error("begin write transaction", e))?;
{
let mut table = txn
.open_table(REGISTRY_TABLE)
.map_err(|e| metadata_error("open registry table", e))?;
let serialized = serde_json::to_vec(record)?;
table
.insert(key, serialized.as_slice())
.map_err(|e| metadata_error("write registry record", e))?;
}
txn.commit()
.map_err(|e| metadata_error("commit registry write", e))?;
Ok(())
}
pub(crate) fn registry_remove_all(appid: &str) -> Result<Vec<String>, LxAppError> {
let legacy_prefix = format!("{appid}::");
let db = database()?;
let txn = db
.begin_write()
.map_err(|e| metadata_error("begin write transaction", e))?;
let mut icon_files = Vec::new();
{
let mut table = txn
.open_table(REGISTRY_TABLE)
.map_err(|e| metadata_error("open registry table", e))?;
let mut keys_to_remove = Vec::new();
for entry in table
.iter()
.map_err(|e| metadata_error("iterate registry records", e))?
{
let (key, value) = entry.map_err(|e| metadata_error("read registry record", e))?;
let key = key.value();
if key != appid && !key.starts_with(&legacy_prefix) {
continue;
}
keys_to_remove.push(key.to_string());
if let Ok(record) = serde_json::from_slice::<RegistryRecord>(value.value())
&& let Some(file) = record.icon_file
{
icon_files.push(file);
}
}
for key in keys_to_remove {
table
.remove(key.as_str())
.map_err(|e| metadata_error("delete registry record", e))?;
}
}
txn.commit()
.map_err(|e| metadata_error("commit registry delete", e))?;
Ok(icon_files)
}
pub(crate) fn registry_referenced_icon_files() -> Result<BTreeSet<String>, LxAppError> {
let db = database()?;
let txn = db
.begin_read()
.map_err(|e| metadata_error("begin read transaction", e))?;
let table = txn
.open_table(REGISTRY_TABLE)
.map_err(|e| metadata_error("open registry table", e))?;
let mut files = BTreeSet::new();
for entry in table
.iter()
.map_err(|e| metadata_error("iterate registry records", e))?
{
let (_, value) = entry.map_err(|e| metadata_error("read registry record", e))?;
if let Ok(record) = serde_json::from_slice::<RegistryRecord>(value.value())
&& let Some(file) = record.icon_file
{
files.insert(file);
}
}
Ok(files)
}
pub(crate) fn installed_paths() -> Result<BTreeSet<String>, LxAppError> {
let db = database()?;
let txn = db
.begin_read()
.map_err(|e| metadata_error("begin read transaction", e))?;
let table = txn
.open_table(INSTALLED_TABLE)
.map_err(|e| metadata_error("open installed table", e))?;
let mut paths = BTreeSet::new();
for entry in table
.iter()
.map_err(|e| metadata_error("iterate installed records", e))?
{
let (_, value) = entry.map_err(|e| metadata_error("read installed record", e))?;
let record: LxAppRecord = serde_json::from_slice(value.value())?;
if !record.install_path.trim().is_empty() {
paths.insert(record.install_path);
}
}
Ok(paths)
}
pub(crate) fn downloaded_archives() -> Result<BTreeSet<String>, LxAppError> {
let db = database()?;
let txn = db
.begin_read()
.map_err(|e| metadata_error("begin read transaction", e))?;
let table = txn
.open_table(DOWNLOADED_TABLE)
.map_err(|e| metadata_error("open downloaded table", e))?;
let mut paths = BTreeSet::new();
for entry in table
.iter()
.map_err(|e| metadata_error("iterate downloaded records", e))?
{
let (_, value) = entry.map_err(|e| metadata_error("read downloaded record", e))?;
let record: PendingUpdateRecord = serde_json::from_slice(value.value())?;
if !record.zip_path.trim().is_empty() {
paths.insert(record.zip_path);
}
}
Ok(paths)
}