use super::page_chrome::{AppearancePreference, ResolvedAppearance};
use super::runtime_registry::{get_lxapps_manager, get_platform};
use crate::error::LxAppError;
use lingxia_platform::traits::app_runtime::AppRuntime;
use lingxia_platform::traits::ui::UIUpdate;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock, RwLock};
use tokio::sync::mpsc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostAppearanceState {
pub preference: AppearancePreference,
pub resolved: ResolvedAppearance,
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HostAppearanceUpdate {
pub revision: u64,
pub state: HostAppearanceState,
}
fn preference_slot() -> &'static RwLock<AppearancePreference> {
static PREFERENCE: OnceLock<RwLock<AppearancePreference>> = OnceLock::new();
PREFERENCE.get_or_init(|| RwLock::new(AppearancePreference::Auto))
}
fn revision_counter() -> &'static AtomicU64 {
static REVISION: AtomicU64 = AtomicU64::new(1);
&REVISION
}
fn last_published() -> &'static Mutex<Option<HostAppearanceState>> {
static LAST: OnceLock<Mutex<Option<HostAppearanceState>>> = OnceLock::new();
LAST.get_or_init(|| Mutex::new(None))
}
fn subscribers() -> &'static Mutex<Vec<mpsc::UnboundedSender<HostAppearanceUpdate>>> {
static SUBSCRIBERS: OnceLock<Mutex<Vec<mpsc::UnboundedSender<HostAppearanceUpdate>>>> =
OnceLock::new();
SUBSCRIBERS.get_or_init(|| Mutex::new(Vec::new()))
}
fn preference() -> AppearancePreference {
*preference_slot()
.read()
.unwrap_or_else(|error| error.into_inner())
}
fn system_dark() -> bool {
get_platform().is_some_and(|platform| platform.host_appearance_dark())
}
pub fn host_appearance_dark() -> bool {
match preference() {
AppearancePreference::Light => false,
AppearancePreference::Dark => true,
AppearancePreference::Auto => system_dark(),
}
}
pub fn host_appearance_state() -> HostAppearanceState {
HostAppearanceState {
preference: preference(),
resolved: if host_appearance_dark() {
ResolvedAppearance::Dark
} else {
ResolvedAppearance::Light
},
}
}
fn host_appearance_update() -> HostAppearanceUpdate {
HostAppearanceUpdate {
revision: revision_counter().load(Ordering::Acquire),
state: host_appearance_state(),
}
}
#[doc(hidden)]
pub fn subscribe_host_appearance() -> (
HostAppearanceUpdate,
mpsc::UnboundedReceiver<HostAppearanceUpdate>,
) {
subscribe_with_snapshot(subscribers(), host_appearance_update)
}
fn subscribe_with_snapshot(
subscribers: &Mutex<Vec<mpsc::UnboundedSender<HostAppearanceUpdate>>>,
snapshot: impl FnOnce() -> HostAppearanceUpdate,
) -> (
HostAppearanceUpdate,
mpsc::UnboundedReceiver<HostAppearanceUpdate>,
) {
let (sender, receiver) = mpsc::unbounded_channel();
{
let mut registered = subscribers
.lock()
.unwrap_or_else(|error| error.into_inner());
registered.retain(|subscriber| !subscriber.is_closed());
registered.push(sender);
}
(snapshot(), receiver)
}
fn starting_preference(
stored: Option<AppearancePreference>,
product_default: AppearancePreference,
) -> AppearancePreference {
stored.unwrap_or(product_default)
}
pub fn initialize_host_appearance(
platform: &lingxia_platform::Platform,
product_default: AppearancePreference,
) {
let stored = lingxia_service::settings::host_appearance(&platform.app_data_dir())
.ok()
.flatten()
.and_then(|value| value.parse::<AppearancePreference>().ok());
let preference = starting_preference(stored, product_default);
*preference_slot()
.write()
.unwrap_or_else(|error| error.into_inner()) = preference;
publish_host_color_mode(platform, preference);
}
pub fn set_host_appearance_preference(
preference: AppearancePreference,
) -> Result<HostAppearanceState, LxAppError> {
let platform = get_platform()
.ok_or_else(|| LxAppError::Runtime("platform runtime is not initialized".to_string()))?;
lingxia_service::settings::set_host_appearance(
&platform.app_data_dir(),
Some(preference.as_str()),
)
.map_err(|error| LxAppError::Runtime(error.to_string()))?;
let changed = {
let mut slot = preference_slot()
.write()
.unwrap_or_else(|error| error.into_inner());
let changed = *slot != preference;
*slot = preference;
changed
};
if changed {
revision_counter().fetch_add(1, Ordering::AcqRel);
publish_host_color_mode(&platform, preference);
super::runtime_ops::refresh_auto_appearances();
notify();
}
Ok(host_appearance_state())
}
pub fn refresh_host_appearance_system() {
let state = host_appearance_state();
{
let mut last = last_published()
.lock()
.unwrap_or_else(|error| error.into_inner());
if *last == Some(state) {
return;
}
*last = Some(state);
}
revision_counter().fetch_add(1, Ordering::AcqRel);
notify();
}
fn notify() {
let update = host_appearance_update();
*last_published()
.lock()
.unwrap_or_else(|error| error.into_inner()) = Some(update.state);
{
let mut registered = subscribers()
.lock()
.unwrap_or_else(|error| error.into_inner());
registered.retain(|subscriber| subscriber.send(update).is_ok());
}
publish_to_logic(&update);
}
fn publish_to_logic(update: &HostAppearanceUpdate) {
let Some(manager) = get_lxapps_manager() else {
return;
};
let payload = format!(
"{{\"revision\":{},\"preference\":\"{}\"}}",
update.revision,
update.state.preference.as_str()
);
let appids: Vec<_> = manager
.lxapps
.iter()
.map(|entry| entry.key().clone())
.collect();
for appid in appids {
crate::appservice::event_bus::publish_app_event(
&appid,
crate::HOST_APPEARANCE_CHANGE_EVENT,
Some(payload.clone()),
);
}
}
fn publish_host_color_mode(
platform: &lingxia_platform::Platform,
preference: AppearancePreference,
) {
let dark = match preference {
AppearancePreference::Auto => None,
AppearancePreference::Light => Some(false),
AppearancePreference::Dark => Some(true),
};
platform.set_host_color_mode(dark);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_allows_native_publication_without_losing_updates() {
let subscribers = Mutex::new(Vec::new());
let update = HostAppearanceUpdate {
revision: 2,
state: HostAppearanceState {
preference: AppearancePreference::Auto,
resolved: ResolvedAppearance::Dark,
},
};
let (initial, mut receiver) = subscribe_with_snapshot(&subscribers, || {
let registered = subscribers
.try_lock()
.expect("snapshot holds subscriber lock");
assert_eq!(registered.len(), 1);
registered[0].send(update).unwrap();
update
});
assert_eq!(initial.revision, update.revision);
assert_eq!(initial.state, update.state);
assert_eq!(receiver.try_recv().unwrap().revision, initial.revision);
let later = HostAppearanceUpdate {
revision: 3,
state: HostAppearanceState {
resolved: ResolvedAppearance::Light,
..update.state
},
};
subscribers.lock().unwrap()[0].send(later).unwrap();
let received = receiver.try_recv().unwrap();
assert!(received.revision > initial.revision);
assert_eq!(received.state, later.state);
}
#[test]
fn a_pinned_preference_resolves_without_the_platform() {
*preference_slot()
.write()
.unwrap_or_else(|error| error.into_inner()) = AppearancePreference::Dark;
assert!(host_appearance_dark());
let state = host_appearance_state();
assert_eq!(state.preference, AppearancePreference::Dark);
assert_eq!(state.resolved, ResolvedAppearance::Dark);
assert!(host_appearance_update().revision >= 1);
*preference_slot()
.write()
.unwrap_or_else(|error| error.into_inner()) = AppearancePreference::Light;
assert!(!host_appearance_dark());
assert_eq!(host_appearance_state().resolved, ResolvedAppearance::Light);
*preference_slot()
.write()
.unwrap_or_else(|error| error.into_inner()) = AppearancePreference::Auto;
assert_eq!(host_appearance_dark(), system_dark());
}
#[test]
fn a_saved_choice_wins_over_the_product_default() {
assert_eq!(
starting_preference(None, AppearancePreference::Dark),
AppearancePreference::Dark
);
assert_eq!(
starting_preference(None, AppearancePreference::Auto),
AppearancePreference::Auto
);
assert_eq!(
starting_preference(Some(AppearancePreference::Auto), AppearancePreference::Dark),
AppearancePreference::Auto
);
assert_eq!(
starting_preference(
Some(AppearancePreference::Light),
AppearancePreference::Dark
),
AppearancePreference::Light
);
}
}