use super::metadata::{self, RegistryRecord};
use super::metadata::{StoredGrant, StoredPermissions};
use super::runtime_registry;
use crate::archive;
use crate::error::LxAppError;
use crate::provider::{
LxAppChannel, LxAppPermissions, LxAppRegistryInfo, LxAppRegistryRequest, LxAppStatus,
lxapp_registry_provider,
};
use lingxia_platform::traits::app_runtime::AppRuntime;
use rong_rt::download as service_executor;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const LISTING_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const STATUS_TTL: Duration = Duration::from_secs(15 * 60);
const OPEN_GATE_TIMEOUT: Duration = Duration::from_secs(3);
const ICON_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const REFRESH_RETRY_INTERVAL: Duration = Duration::from_secs(60);
const STAGING_MAX_AGE: Duration = Duration::from_secs(60 * 60);
const ICONS_DIR: &str = "icons";
const STAGING_SUFFIX: &str = ".part";
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs() as i64)
.unwrap_or(0)
}
fn icons_dir() -> Option<PathBuf> {
let runtime = runtime_registry::get_platform()?;
Some(
runtime
.app_cache_dir()
.join(super::LINGXIA_DIR)
.join(super::LXAPPS_DIR)
.join(ICONS_DIR),
)
}
fn ensure_icons_dir() -> Option<PathBuf> {
let dir = icons_dir()?;
if let Err(err) = fs::create_dir_all(&dir) {
crate::warn!("Failed to create lxapp icon cache dir: {}", err);
return None;
}
Some(dir)
}
fn icon_extension(url: &str) -> &'static str {
let path = url.split(&['?', '#'][..]).next().unwrap_or(url);
let ext = path
.rsplit('/')
.next()
.and_then(|segment| segment.rsplit_once('.'))
.map(|(_, ext)| ext.to_ascii_lowercase());
match ext.as_deref() {
Some("svg") => "svg",
Some("jpg") | Some("jpeg") => "jpg",
Some("webp") => "webp",
Some("ico") => "ico",
_ => "png",
}
}
fn active_refreshes() -> &'static Mutex<HashSet<String>> {
static ACTIVE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
ACTIVE.get_or_init(|| Mutex::new(HashSet::new()))
}
struct RefreshGuard(String);
impl RefreshGuard {
fn acquire(key: String) -> Option<Self> {
let mut active = active_refreshes()
.lock()
.unwrap_or_else(|err| err.into_inner());
active.insert(key.clone()).then(|| Self(key))
}
}
impl Drop for RefreshGuard {
fn drop(&mut self) {
if let Ok(mut active) = active_refreshes().lock() {
active.remove(&self.0);
}
}
}
fn last_attempts() -> &'static Mutex<HashMap<String, Instant>> {
static ATTEMPTS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
ATTEMPTS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn attempted_recently(appid: &str) -> bool {
let attempts = last_attempts()
.lock()
.unwrap_or_else(|err| err.into_inner());
attempts
.get(appid)
.is_some_and(|at| at.elapsed() < REFRESH_RETRY_INTERVAL)
}
fn mark_attempted(appid: &str) {
last_attempts()
.lock()
.unwrap_or_else(|err| err.into_inner())
.insert(appid.to_string(), Instant::now());
}
type RegistryChangeListener = Box<dyn Fn(&[String]) + Send + Sync>;
fn change_listener() -> &'static Mutex<Option<RegistryChangeListener>> {
static LISTENER: OnceLock<Mutex<Option<RegistryChangeListener>>> = OnceLock::new();
LISTENER.get_or_init(|| Mutex::new(None))
}
pub fn set_registry_change_listener(listener: RegistryChangeListener) {
*change_listener().lock().unwrap_or_else(|e| e.into_inner()) = Some(listener);
}
pub fn registry_unavailable_status(error: &LxAppError) -> Option<LxAppStatus> {
let LxAppError::RongJSHost {
data: Some(data), ..
} = error
else {
return None;
};
let code = data.get("code")?.as_str()?;
let status = LxAppStatus::from_str_lossy(code);
status.blocks_open().then_some(status)
}
type OpenBlockedListener = Box<dyn Fn(LxAppStatus) + Send + Sync>;
fn open_blocked_listener() -> &'static Mutex<Option<OpenBlockedListener>> {
static LISTENER: OnceLock<Mutex<Option<OpenBlockedListener>>> = OnceLock::new();
LISTENER.get_or_init(|| Mutex::new(None))
}
pub fn set_open_blocked_listener(listener: OpenBlockedListener) {
*open_blocked_listener()
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(listener);
}
pub fn notify_open_blocked(error: &LxAppError) {
if let Some(status) = registry_unavailable_status(error) {
let guard = open_blocked_listener()
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(listener) = guard.as_ref() {
listener(status);
return;
}
}
crate::warn!("lxapp open blocked: {}", error);
}
fn notify_changed(appids: &[String]) {
if appids.is_empty() {
return;
}
let guard = change_listener().lock().unwrap_or_else(|e| e.into_inner());
if let Some(listener) = guard.as_ref() {
listener(appids);
}
}
fn record(appid: &str) -> Option<RegistryRecord> {
metadata::registry_get(appid).ok().flatten()
}
fn is_expired(record: &RegistryRecord, ttl: Duration) -> bool {
stamp_is_expired(record.fetched_at, ttl)
}
fn stamp_is_expired(fetched_at: i64, ttl: Duration) -> bool {
let now = now_secs();
now < fetched_at || now - fetched_at > ttl.as_secs() as i64
}
fn cached_icon_is_gone(record: &RegistryRecord, icons_dir: &Path) -> bool {
record
.icon_file
.as_ref()
.is_some_and(|file| !icons_dir.join(file).exists())
}
pub(crate) fn name(appid: &str) -> Option<String> {
lxapp_registry_provider()?;
record(appid)
.and_then(|record| record.name)
.filter(|name| !name.trim().is_empty())
}
pub(crate) fn icon_path(appid: &str) -> Option<String> {
lxapp_registry_provider()?;
let file = record(appid).and_then(|record| record.icon_file)?;
let path = icons_dir()?.join(file);
path.exists().then(|| path.to_string_lossy().into_owned())
}
pub(crate) fn status(appid: &str) -> LxAppStatus {
if lxapp_registry_provider().is_none() {
return LxAppStatus::Unknown;
}
record(appid)
.map(|record| LxAppStatus::from_str_lossy(&record.status))
.unwrap_or_default()
}
pub(crate) fn ensure_fresh(appids: &[String]) {
if lxapp_registry_provider().is_none() {
return;
}
let icons_dir = icons_dir();
for appid in appids {
let cached = record(appid);
let needs_fetch = match (&cached, &icons_dir) {
(None, _) => true,
(Some(cached), Some(icons_dir)) => {
is_expired(cached, LISTING_TTL) || cached_icon_is_gone(cached, icons_dir)
}
(Some(cached), None) => is_expired(cached, LISTING_TTL),
};
if !needs_fetch || attempted_recently(appid) {
continue;
}
mark_attempted(appid);
let appid = appid.clone();
std::mem::drop(crate::executor::spawn(Box::pin(async move {
let Some(_guard) = RefreshGuard::acquire(appid.clone()) else {
return;
};
match fetch_records(&appid, crate::default_channel().into()).await {
Ok(Some(info)) => fetch_icons(&appid, &info).await,
Ok(None) => {}
Err(err) => {
crate::warn!("lxapp registry refresh failed: {}", err);
}
}
})));
}
}
pub async fn ensure_open_allowed(appid: &str) -> Result<(), LxAppError> {
if lxapp_registry_provider().is_none() {
return Ok(());
}
let fresh = record(appid)
.filter(|record| !is_expired(record, STATUS_TTL))
.map(|record| LxAppStatus::from_str_lossy(&record.status));
let status = match fresh {
Some(status) => status,
None => {
let channel = crate::default_channel().into();
match tokio::time::timeout(OPEN_GATE_TIMEOUT, fetch_records(appid, channel)).await {
Ok(Ok(info)) => {
if let Some(info) = info.clone() {
let appid = appid.to_string();
std::mem::drop(crate::executor::spawn(Box::pin(async move {
fetch_icons(&appid, &info).await;
})));
}
info.map(|info| info.status).unwrap_or_default()
}
Ok(Err(err)) => {
crate::warn!("Registry status check failed for {}: {}", appid, err)
.with_appid(appid);
return Ok(());
}
Err(_) => {
crate::warn!("Registry status check timed out for {}", appid).with_appid(appid);
return Ok(());
}
}
}
};
if status.blocks_open() {
return Err(unavailable_error(appid, status));
}
Ok(())
}
fn unavailable_error(appid: &str, status: LxAppStatus) -> LxAppError {
LxAppError::RongJSHost {
code: "3000".to_string(),
message: format!("lxapp {appid} is {status} and cannot be opened"),
data: Some(serde_json::json!({
"bizCode": 3000,
"code": status.as_str(),
"appId": appid,
})),
}
}
pub(crate) async fn fetch_records(
appid: &str,
channel: LxAppChannel,
) -> Result<Option<LxAppRegistryInfo>, LxAppError> {
let Some(provider) = lxapp_registry_provider() else {
return Ok(None);
};
if appid.trim().is_empty() {
return Ok(None);
}
let info = provider
.fetch_registry_info(LxAppRegistryRequest::new(appid, channel))
.await
.map_err(|err| crate::provider::provider_error_to_lxapp_error(&err))?;
mark_attempted(appid);
let previous = record(appid);
let stored = RegistryRecord {
appid: appid.to_string(),
name: info.as_ref().and_then(|info| info.name.clone()),
description: info.as_ref().and_then(|info| info.description.clone()),
icon_url: info.as_ref().and_then(|info| info.icon_url.clone()),
icon_file: carry_icon_file(info.as_ref(), previous.as_ref()),
status: info
.as_ref()
.map(|info| info.status)
.unwrap_or_default()
.as_str()
.to_string(),
grants: carry_grants(info.as_ref(), previous.as_ref(), channel),
fetched_at: now_secs(),
};
if let Err(err) = metadata::registry_upsert(&stored) {
crate::warn!("Failed to cache registry record for {}: {}", appid, err);
}
let changed = [appid.to_string()];
notify_changed(&changed);
Ok(info)
}
pub(crate) fn cached_grant(appid: &str, channel: LxAppChannel) -> Option<Option<LxAppPermissions>> {
grant_of(&record(appid)?, channel, Some(STATUS_TTL))
}
pub(crate) fn standing_grant(appid: &str, channel: LxAppChannel) -> Option<LxAppPermissions> {
grant_of(&record(appid)?, channel, None).flatten()
}
fn grant_of(
record: &RegistryRecord,
channel: LxAppChannel,
ttl: Option<Duration>,
) -> Option<Option<LxAppPermissions>> {
let grant = record.grants.get(channel.as_str())?;
if ttl.is_some_and(|ttl| stamp_is_expired(grant.fetched_at, ttl)) {
return None;
}
Some(grant.permissions.as_ref().map(load_permissions))
}
pub(crate) async fn resolve_grant(appid: &str, channel: LxAppChannel) -> Option<LxAppPermissions> {
match fetch_records(appid, channel).await {
Ok(info) => info.and_then(|info| info.permissions),
Err(err) => {
crate::warn!("Registry grant lookup failed for {}: {}", appid, err).with_appid(appid);
standing_grant(appid, channel)
}
}
}
fn store_permissions(permissions: &LxAppPermissions) -> StoredPermissions {
StoredPermissions {
domains: permissions
.network
.as_ref()
.map(|network| network.trusted_domains.clone()),
privileges: permissions
.privileges
.as_ref()
.map(|privileges| privileges.granted.clone()),
}
}
fn load_permissions(stored: &StoredPermissions) -> LxAppPermissions {
let mut permissions = LxAppPermissions::all();
if let Some(domains) = stored.domains.clone() {
permissions = permissions.with_network(domains);
}
if let Some(privileges) = stored.privileges.clone() {
permissions = permissions.with_privileges(privileges);
}
permissions
}
fn carry_grants(
info: Option<&LxAppRegistryInfo>,
previous: Option<&RegistryRecord>,
channel: LxAppChannel,
) -> BTreeMap<String, StoredGrant> {
let mut grants = previous
.map(|previous| previous.grants.clone())
.unwrap_or_default();
grants.insert(
channel.as_str().to_string(),
StoredGrant {
permissions: info
.and_then(|info| info.permissions.as_ref())
.map(store_permissions),
fetched_at: now_secs(),
},
);
grants
}
fn carry_icon_file(
info: Option<&LxAppRegistryInfo>,
previous: Option<&RegistryRecord>,
) -> Option<String> {
match info {
Some(info) => {
let new_url = info.icon_url.as_deref().filter(|url| !url.is_empty());
let old_url = previous
.and_then(|previous| previous.icon_url.as_deref())
.filter(|url| !url.is_empty());
if new_url.is_some() && new_url == old_url {
previous.and_then(|previous| previous.icon_file.clone())
} else {
None
}
}
None => previous.and_then(|previous| previous.icon_file.clone()),
}
}
async fn fetch_icons(appid: &str, info: &LxAppRegistryInfo) {
sweep_staging_files();
let cached = record(appid);
let Some(icon_file) = resolve_icon_file(appid, info, cached.as_ref()).await else {
return;
};
let Some(mut record) = cached else {
return;
};
if record.icon_file.as_deref() == Some(icon_file.as_str()) {
return;
}
record.icon_file = Some(icon_file);
if let Err(err) = metadata::registry_upsert(&record) {
crate::warn!("Failed to cache registry icon for {}: {}", appid, err);
return;
}
notify_changed(&[appid.to_string()]);
}
async fn resolve_icon_file(
appid: &str,
info: &LxAppRegistryInfo,
cached: Option<&RegistryRecord>,
) -> Option<String> {
let url = info.icon_url.as_deref().filter(|url| !url.is_empty())?;
let dir = ensure_icons_dir()?;
let extension = icon_extension(url);
if let Some(cached) = cached
&& cached.icon_url.as_deref() == Some(url)
&& let Some(file) = cached.icon_file.as_deref()
&& dir.join(file).exists()
{
return Some(file.to_string());
}
let staging = dir.join(format!(
"download-{}{}",
uuid::Uuid::new_v4(),
STAGING_SUFFIX
));
let options = service_executor::DownloadOptions::new(url.to_string(), staging.clone())
.with_connect_timeout(Duration::from_secs(10))
.with_request_timeout(ICON_REQUEST_TIMEOUT);
let receiver = service_executor::spawn_download(options, None)
.map_err(|err| crate::warn!("Failed to start icon download: {}", err))
.ok()?;
match receiver.await {
Ok(Ok(())) => {}
Ok(Err(err)) => {
crate::warn!("Icon download failed for {}: {}", appid, err);
let _ = fs::remove_file(&staging);
return None;
}
Err(_) => {
let _ = fs::remove_file(&staging);
return None;
}
}
let digest = archive::sha256_hex(&staging).ok()?;
let file = format!("{}.{}", digest, extension);
let destination = dir.join(&file);
if destination.exists() {
let _ = fs::remove_file(&staging);
return Some(file);
}
if let Err(err) = fs::rename(&staging, &destination) {
let _ = fs::remove_file(&staging);
if !destination.exists() {
crate::warn!("Failed to store cached icon for {}: {}", appid, err);
return None;
}
}
Some(file)
}
fn sweep_staging_files() {
let Some(dir) = icons_dir() else {
return;
};
let Ok(entries) = fs::read_dir(&dir) else {
return;
};
for entry in entries.flatten() {
if !entry
.file_name()
.to_string_lossy()
.ends_with(STAGING_SUFFIX)
{
continue;
}
let stale = entry
.metadata()
.and_then(|metadata| metadata.modified())
.map(|modified| {
modified
.elapsed()
.is_ok_and(|elapsed| elapsed > STAGING_MAX_AGE)
})
.unwrap_or(false);
if stale {
let _ = fs::remove_file(entry.path());
}
}
}
pub(crate) fn clear(appid: &str) {
let orphan_candidates = match metadata::registry_remove_all(appid) {
Ok(files) => files,
Err(err) => {
crate::warn!("Failed to clear registry cache for {}: {}", appid, err);
return;
}
};
if orphan_candidates.is_empty() {
return;
}
let Some(dir) = icons_dir() else {
return;
};
sweep_orphan_icons(&orphan_candidates, &dir);
}
fn sweep_orphan_icons(candidates: &[String], icons_dir: &Path) {
let Ok(still_referenced) = metadata::registry_referenced_icon_files() else {
return;
};
for file in candidates {
if !still_referenced.contains(file) {
let _ = fs::remove_file(icons_dir.join(file));
}
}
}
pub fn display_name(appid: &str) -> Option<String> {
name(appid)
.or_else(|| runtime_registry::try_get(appid).map(|app| app.get_lxapp_info().app_name))
.filter(|name| !name.trim().is_empty())
}
pub fn display_icon_path(appid: &str) -> Option<String> {
icon_path(appid).filter(|path| !path.trim().is_empty())
}
pub fn display_status(appid: &str) -> LxAppStatus {
status(appid)
}
pub fn refresh_registry(appids: &[String]) {
ensure_fresh(appids);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn icon_extension_keeps_known_image_types_and_defaults_to_png() {
assert_eq!(icon_extension("https://cdn.example.com/a/logo.svg"), "svg");
assert_eq!(icon_extension("https://cdn.example.com/a/logo.JPEG"), "jpg");
assert_eq!(
icon_extension("https://cdn.example.com/a/logo.webp?v=2"),
"webp"
);
assert_eq!(icon_extension("https://cdn.example.com/icon/42"), "png");
assert_eq!(icon_extension("https://cdn.example.com/a/logo.bin"), "png");
}
fn constrained(domain: &str) -> LxAppRegistryInfo {
LxAppRegistryInfo {
permissions: Some(LxAppPermissions::network([domain])),
..LxAppRegistryInfo::default()
}
}
#[test]
fn a_grant_belongs_to_the_channel_it_was_answered_for() {
let mut record = record_for("demo", None);
record.grants = carry_grants(
Some(&constrained("api.example.com")),
None,
LxAppChannel::Release,
);
let release = grant_of(&record, LxAppChannel::Release, Some(STATUS_TTL))
.expect("a current answer")
.expect("an explicit grant");
assert_eq!(
release.network.map(|network| network.trusted_domains),
Some(vec!["api.example.com".to_string()])
);
assert!(release.privileges.is_none());
assert!(grant_of(&record, LxAppChannel::Draft, Some(STATUS_TTL)).is_none());
assert!(grant_of(&record, LxAppChannel::Draft, None).is_none());
record
.grants
.get_mut("release")
.expect("the release grant")
.fetched_at -= STATUS_TTL.as_secs() as i64 + 1;
assert!(grant_of(&record, LxAppChannel::Release, Some(STATUS_TTL)).is_none());
assert!(
grant_of(&record, LxAppChannel::Release, None)
.unwrap()
.is_some()
);
}
#[test]
fn a_refresh_on_one_channel_leaves_the_others_alone() {
let draft = carry_grants(
Some(&constrained("dev.example.com")),
None,
LxAppChannel::Draft,
);
let mut record = record_for("demo", None);
record.grants = draft;
record.grants = carry_grants(
Some(&constrained("api.example.com")),
Some(&record),
LxAppChannel::Release,
);
for (channel, host) in [
(LxAppChannel::Draft, "dev.example.com"),
(LxAppChannel::Release, "api.example.com"),
] {
let grant = grant_of(&record, channel, Some(STATUS_TTL))
.expect("a current answer")
.expect("an explicit grant");
assert_eq!(
grant.network.map(|network| network.trusted_domains),
Some(vec![host.to_string()]),
"{channel}"
);
}
}
#[test]
fn a_channel_with_no_grant_is_a_current_answer_of_no_constraint() {
let mut record = record_for("demo", None);
record.grants = carry_grants(None, None, LxAppChannel::Release);
assert!(
grant_of(&record, LxAppChannel::Release, Some(STATUS_TTL))
.expect("a current answer")
.is_none()
);
assert!(grant_of(&record, LxAppChannel::Draft, Some(STATUS_TTL)).is_none());
}
fn record_for(appid: &str, icon_file: Option<&str>) -> RegistryRecord {
RegistryRecord {
appid: appid.to_string(),
name: Some(format!("{appid}-name")),
description: None,
icon_url: None,
icon_file: icon_file.map(str::to_string),
status: LxAppStatus::Published.as_str().to_string(),
grants: BTreeMap::new(),
fetched_at: now_secs(),
}
}
#[test]
fn expiry_uses_the_ttl_it_is_given() {
let mut record = record_for("demo", None);
assert!(!is_expired(&record, STATUS_TTL));
record.fetched_at = now_secs() - (STATUS_TTL.as_secs() as i64) - 1;
assert!(is_expired(&record, STATUS_TTL));
assert!(!is_expired(&record, LISTING_TTL));
}
#[test]
fn a_record_stamped_in_the_future_is_expired_not_eternally_fresh() {
let mut record = record_for("demo", None);
record.fetched_at = now_secs() + 60 * 60 * 24 * 365;
assert!(is_expired(&record, STATUS_TTL));
assert!(is_expired(&record, LISTING_TTL));
}
#[test]
fn only_suspended_blocks_opening() {
assert!(LxAppStatus::Suspended.blocks_open());
assert!(!LxAppStatus::Delisted.blocks_open());
assert!(!LxAppStatus::Published.blocks_open());
assert!(!LxAppStatus::Unknown.blocks_open());
}
#[test]
fn unknown_server_states_degrade_instead_of_blocking() {
let status = LxAppStatus::from_str_lossy("quarantined-pending-review");
assert_eq!(status, LxAppStatus::Unknown);
assert!(!status.blocks_open());
}
fn with_store<T>(body: impl FnOnce(&Path) -> T) -> T {
static STORE: OnceLock<Mutex<PathBuf>> = OnceLock::new();
let dir = STORE.get_or_init(|| {
let root = std::env::temp_dir().join(format!("lx-registry-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&root).expect("create test cache root");
metadata::init(root.join("metadata.redb")).expect("init metadata database");
Mutex::new(root)
});
let root = dir.lock().unwrap_or_else(|err| err.into_inner());
body(&root)
}
#[test]
fn uninstall_keeps_artwork_another_app_still_references() {
with_store(|icons| {
let shared = "shared-artwork.png";
let solo = "solo-artwork.png";
fs::write(icons.join(shared), b"shared").unwrap();
fs::write(icons.join(solo), b"solo").unwrap();
metadata::registry_upsert(&record_for("com.example.keeper", Some(shared))).unwrap();
metadata::registry_upsert(&record_for("com.example.leaver", Some(shared))).unwrap();
metadata::registry_upsert(&record_for("com.example.other", Some(solo))).unwrap();
let orphans = metadata::registry_remove_all("com.example.leaver").unwrap();
sweep_orphan_icons(&orphans, icons);
assert!(
metadata::registry_get("com.example.leaver")
.unwrap()
.is_none()
);
assert!(
icons.join(shared).exists(),
"still referenced by the keeper"
);
assert!(icons.join(solo).exists(), "still referenced by the other");
let orphans = metadata::registry_remove_all("com.example.other").unwrap();
sweep_orphan_icons(&orphans, icons);
assert!(!icons.join(solo).exists(), "last reference went away");
let orphans = metadata::registry_remove_all("com.example.keeper").unwrap();
sweep_orphan_icons(&orphans, icons);
assert!(!icons.join(shared).exists());
});
}
#[test]
fn artwork_the_os_purged_counts_as_stale_however_fresh_the_record_is() {
let dir = std::env::temp_dir().join(format!("lx-icon-gone-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).unwrap();
let present = "kept.png";
fs::write(dir.join(present), b"art").unwrap();
let mut record = record_for("demo", Some(present));
assert!(!cached_icon_is_gone(&record, &dir));
record.icon_file = Some("purged.png".to_string());
assert!(!is_expired(&record, LISTING_TTL));
assert!(cached_icon_is_gone(&record, &dir));
record.icon_file = None;
assert!(!cached_icon_is_gone(&record, &dir));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_blocked_open_reports_which_state_blocked_it() {
for status in [LxAppStatus::Suspended, LxAppStatus::Maintain] {
let error = unavailable_error("com.example.app", status);
assert_eq!(registry_unavailable_status(&error), Some(status));
let LxAppError::RongJSHost { data, message, .. } = error else {
panic!("a blocked open must carry structured data, got a bare error");
};
let data = data.expect("blocked open carries data");
assert_eq!(data["code"], status.as_str());
assert_eq!(data["appId"], "com.example.app");
assert!(message.contains("com.example.app"));
}
}
#[test]
fn artwork_is_kept_only_when_its_url_is_unchanged() {
let previous = RegistryRecord {
appid: "demo".to_string(),
name: None,
description: None,
icon_url: Some("https://cdn.example.com/a.png".to_string()),
icon_file: Some("old.png".to_string()),
status: LxAppStatus::Published.as_str().to_string(),
grants: BTreeMap::new(),
fetched_at: now_secs(),
};
let same = LxAppRegistryInfo {
icon_url: Some("https://cdn.example.com/a.png".to_string()),
..Default::default()
};
assert_eq!(
carry_icon_file(Some(&same), Some(&previous)).as_deref(),
Some("old.png")
);
let changed = LxAppRegistryInfo {
icon_url: Some("https://cdn.example.com/b.png".to_string()),
..same.clone()
};
assert_eq!(carry_icon_file(Some(&changed), Some(&previous)), None);
let withdrawn = LxAppRegistryInfo {
icon_url: None,
..same.clone()
};
assert_eq!(carry_icon_file(Some(&withdrawn), Some(&previous)), None);
let empty = LxAppRegistryInfo {
icon_url: Some(String::new()),
..same
};
assert_eq!(carry_icon_file(Some(&empty), Some(&previous)), None);
assert_eq!(
carry_icon_file(None, Some(&previous)).as_deref(),
Some("old.png")
);
}
#[test]
fn a_recent_attempt_suppresses_the_next_refresh() {
let appid = "com.example.backoff";
assert!(!attempted_recently(appid));
mark_attempted(appid);
assert!(attempted_recently(appid));
assert!(!attempted_recently("com.example.other"));
}
}