Files
galaxy/crates/settings/src/lib.rs
T

682 lines
25 KiB
Rust

#[macro_use]
pub mod macros;
pub mod manager;
pub mod schema;
// Re-export commonly used types and traits
use std::fmt::Debug;
use std::ops::Deref;
// Re-export galaxyui_core for use by macros.
pub use galaxyui_core;
// Re-export crates used by macro expansions in downstream crates.
#[doc(hidden)]
pub use inventory as _inventory;
pub use macros::SettingSection;
pub use manager::SettingsManager;
#[doc(hidden)]
pub use schemars as _schemars;
#[doc(hidden)]
pub use settings_value as _settings_value;
pub use settings_value::SettingsValue;
/// Extracts the storage key (last segment after the final `.`) from a toml_path.
///
/// # Examples
/// - `"appearance.text.font_name"` → `"font_name"`
/// - `"font_name"` → `"font_name"`
pub const fn toml_path_storage_key(path: &str) -> &str {
let bytes = path.as_bytes();
let mut i = path.len();
while i > 0 {
i -= 1;
if bytes[i] == b'.' {
let (_, suffix) = path.split_at(i + 1);
return suffix;
}
}
path
}
/// Extracts the hierarchy (everything before the final `.`) from a toml_path.
///
/// Returns `None` when the path contains no dot (the path is just a key).
///
/// # Examples
/// - `"appearance.text.font_name"` → `Some("appearance.text")`
/// - `"font_name"` → `None`
pub const fn toml_path_hierarchy(path: &str) -> Option<&str> {
let bytes = path.as_bytes();
let mut i = path.len();
while i > 0 {
i -= 1;
if bytes[i] == b'.' {
let (prefix, _) = path.split_at(i);
return Some(prefix);
}
}
None
}
use anyhow::{Context, Result};
use galaxy_features::FeatureFlag;
use galaxyui_core::{AppContext, Entity, ModelContext};
use galaxyui_extras::secure_storage::{self, AppContextExt as _};
use galaxyui_extras::user_preferences::UserPreferences;
use serde::Serialize;
use serde::de::DeserializeOwned;
/// A newtype wrapper for the public preferences backend.
///
/// Public settings (those marked `private: false` in `define_settings_group!`)
/// are stored in the user-visible settings file (TOML) when the `SettingsFile`
/// feature flag is enabled, otherwise in the platform-native store.
///
/// The inner field is private and only accessible within the settings crate via
/// [`as_preferences`](Self::as_preferences). This prevents external code from
/// bypassing the settings macros to read/write public preferences directly.
pub struct PublicPreferences(Box<dyn UserPreferences>);
impl PublicPreferences {
pub fn new(prefs: Box<dyn UserPreferences>) -> Self {
Self(prefs)
}
/// Returns the underlying preferences backend.
///
/// This is intentionally `pub(crate)` so that only the settings
/// infrastructure (macros, `Setting` trait, `SettingsManager`) can access
/// the raw backend. External code must go through typed settings groups
/// produced by `define_settings_group!`.
pub(crate) fn as_preferences(&self) -> &dyn UserPreferences {
self.0.as_ref()
}
/// Returns whether this backend is the user-visible settings file.
pub fn is_settings_file(&self) -> bool {
self.0.is_settings_file()
}
/// Reloads the backing store from disk.
pub fn reload_from_disk(&self) -> Result<(), galaxyui_extras::user_preferences::Error> {
self.0.reload_from_disk()
}
}
impl galaxyui_core::Entity for PublicPreferences {
type Event = ();
}
impl galaxyui_core::SingletonEntity for PublicPreferences {}
/// A newtype wrapper for the private preferences backend.
///
/// Private settings (those marked `private: true` in `define_settings_group!`)
/// are stored here instead of in the user-visible settings file. This always
/// uses the platform-native store (e.g. UserDefaults on macOS, JSON file on
/// Linux, registry on Windows).
pub struct PrivatePreferences(Box<dyn UserPreferences>);
impl PrivatePreferences {
pub fn new(prefs: Box<dyn UserPreferences>) -> Self {
Self(prefs)
}
}
impl Deref for PrivatePreferences {
type Target = dyn UserPreferences;
fn deref(&self) -> &Self::Target {
self.0.as_ref()
}
}
impl galaxyui_core::Entity for PrivatePreferences {
type Event = ();
}
impl galaxyui_core::SingletonEntity for PrivatePreferences {}
/// An enum representing the different platforms a setting could apply to.
#[derive(Debug, Clone)]
pub enum SupportedPlatforms {
ALL,
DESKTOP, /* Refers to running on device, not web-based, such as Mac, Linux, and Windows */
MAC,
LINUX,
WINDOWS,
WEB,
OR(Box<SupportedPlatforms>, Box<SupportedPlatforms>),
}
/// An enum representing the different ways a setting can be synced to the cloud.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncToCloud {
/// The setting is synced to the cloud as a single global value that applies to on all supported platforms.
Globally(RespectUserSyncSetting),
/// The setting is synced to the cloud as a value that is unique to each platform.
PerPlatform(RespectUserSyncSetting),
/// The setting is not synced to the cloud.
Never,
}
/// Whether for this setting we respect the user toggle for settings sync.
/// There are some cases we want to sync settings regardless of the user setting,
/// such as for the value of whether cloud syncing is enabled, whether telemetry is enabled, etc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RespectUserSyncSetting {
/// Only sync if the user has settings sync enabled
Yes,
/// Sync regardless of the user's setting
No,
}
impl SupportedPlatforms {
pub fn matches_current_platform(&self) -> bool {
match self {
SupportedPlatforms::ALL => true,
SupportedPlatforms::DESKTOP => {
cfg!(not(target_family = "wasm"))
}
SupportedPlatforms::MAC => {
cfg!(all(not(target_family = "wasm"), target_os = "macos"))
}
SupportedPlatforms::LINUX => {
cfg!(all(
not(target_family = "wasm"),
any(target_os = "linux", target_os = "freebsd")
))
}
SupportedPlatforms::WINDOWS => {
cfg!(all(not(target_family = "wasm"), target_os = "windows"))
}
SupportedPlatforms::WEB => {
cfg!(target_family = "wasm")
}
SupportedPlatforms::OR(first, second) => {
first.matches_current_platform() || second.matches_current_platform()
}
}
}
}
/// An enum representing the reason for a change event.
#[derive(Debug, Clone, Copy)]
pub enum ChangeEventReason {
/// The change was initiated from a cloud sync
CloudSync,
/// The change was initiated from a local setting change
LocalChange,
/// The change was initiated from a clear operation
Clear,
}
/// A representation of a setting which can be loaded from and persisted to some
/// sort of durable storage.
pub trait Setting {
type Group: Entity;
type Value: Serialize + DeserializeOwned + PartialEq + Debug + SettingsValue;
/// Constructs this setting object with the given initial value.
/// If value is None, uses the default value and marks as not explicitly set.
/// If value is Some, uses that value and marks as explicitly set.
fn new(value: Option<Self::Value>) -> Self
where
Self: Sized;
/// Returns the name of the setting.
fn setting_name() -> &'static str;
/// Returns the key underwhich this setting should be stored. Should be
/// distinct from all other settings, and should not change over time.
fn storage_key() -> &'static str;
/// Returns the full TOML path for this setting, if any.
///
/// The toml_path is a dot-separated path that includes both the hierarchy
/// (section) and the storage key as the last segment. For example,
/// `"appearance.text.font_name"` means the setting lives under
/// `[appearance.text]` with key `font_name`.
fn toml_path() -> Option<&'static str> {
None
}
/// Returns the key used in the TOML settings file.
///
/// For settings with a `toml_path`, this is the last segment (e.g.
/// `"font_name"` from `"appearance.text.font_name"`). For settings
/// without a `toml_path`, falls back to `storage_key()`.
fn toml_key() -> &'static str {
Self::storage_key()
}
/// Returns the hierarchy path for this setting, if any.
///
/// When set, hierarchy-aware preferences backends use this to organize
/// settings into logical groups. For example, a hierarchy of `"font"`
/// places the setting under a `font` section in the backing store.
fn hierarchy() -> Option<&'static str> {
None
}
/// Returns the maximum number of TOML section-table levels to use when
/// rendering this setting's value in the settings file.
///
/// - `None` (default) — unlimited depth; nested objects become section
/// tables (`[section.subsection]`) all the way down.
/// - `Some(0)` — the value itself is rendered as an inline table
/// (`key = { ... }`). Used for enum settings whose shape changes between
/// variants.
/// - `Some(1)` — the setting gets its own section header, but any nested
/// objects within it are rendered inline. Useful for struct settings
/// that contain maps or nested structs.
fn max_table_depth() -> Option<u32> {
None
}
/// Returns the platforms that this setting is supported on.
fn supported_platforms() -> SupportedPlatforms;
/// Returns whether and how this setting is synced to the cloud via Warp Drive.
fn sync_to_cloud() -> SyncToCloud;
/// Returns whether this setting is private (not shown in the user-visible settings file).
///
/// Private settings are persisted to the platform-native store (e.g. UserDefaults on
/// macOS) rather than the TOML settings file, ensuring they never appear in the
/// user-editable file.
fn is_private() -> bool;
/// Returns whether the current value of this setting should be synced.
/// Only applies if sync_to_cloud() returns a value other than SyncToCloud::Never.
/// Specific settings can implement this to filter which values should be synced.
fn current_value_is_syncable(&self) -> bool {
true
}
/// Returns whether the current value of this setting is syncable on the current platform,
/// given the user's settings sync preference.
fn is_setting_syncable_on_current_platform(&self, settings_sync_enabled: bool) -> bool {
if !self.current_value_is_syncable() {
return false;
}
match (Self::sync_to_cloud(), settings_sync_enabled) {
(SyncToCloud::Never, _) => false,
(SyncToCloud::Globally(RespectUserSyncSetting::No), _) => true,
(SyncToCloud::Globally(RespectUserSyncSetting::Yes), true) => true,
(SyncToCloud::Globally(RespectUserSyncSetting::Yes), false) => false,
(SyncToCloud::PerPlatform(RespectUserSyncSetting::No), _) => {
self.is_supported_on_current_platform()
}
(SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes), true) => {
self.is_supported_on_current_platform()
}
(SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes), false) => false,
}
}
/// Returns the current value of the setting. This may be different from
/// the value persisted in storage.
fn value(&self) -> &Self::Value;
/// Validates whether the new value is valid and returns a valid value to
/// use. If the provided value is valid, the expectation is that this will
/// return the provided value. If not, it is up to the implementation
/// whether the current value of the setting is returned or whether some
/// other value is returned.
fn validate(&self, new_value: Self::Value) -> Self::Value {
new_value
}
/// Clears the value of the setting from persistent storage and fires a change event
/// indicating that the value was cleared.
fn clear_value(&mut self, ctx: &mut ModelContext<Self::Group>) -> anyhow::Result<()>;
/// Loads a value into memory without persisting it to storage.
///
/// Used during hot-reload to sync in-memory state with the file on disk.
/// Unlike [`set_value`](Self::set_value), this never writes to the
/// preferences backend, avoiding write-back loops with the file watcher.
fn load_value(
&mut self,
new_value: Self::Value,
explicitly_set: bool,
ctx: &mut ModelContext<Self::Group>,
) -> anyhow::Result<()>;
/// Sets the value of the setting persisting it to storage. The change event indicates
/// that the update was initiated from a cloud sync.
fn set_value_from_cloud_sync(
&mut self,
new_value: Self::Value,
ctx: &mut galaxyui_core::ModelContext<Self::Group>,
) -> anyhow::Result<()>;
/// Sets the value of the setting persisting it to storage.
fn set_value(
&mut self,
new_value: Self::Value,
ctx: &mut ModelContext<Self::Group>,
) -> Result<()>;
/// Returns the default value of the setting.
fn default_value() -> Self::Value;
/// Sets the value of the setting to its default and persists it to storage.
fn set_value_to_default(
&mut self,
ctx: &mut galaxyui_core::ModelContext<Self::Group>,
) -> anyhow::Result<()> {
self.set_value(Self::default_value(), ctx)
}
/// Returns the appropriate preferences backend for this setting.
///
/// Private settings use the platform-native store; public settings use
/// the main preferences backend (which may be the TOML settings file).
fn preferences_for_setting(ctx: &AppContext) -> &dyn UserPreferences {
use galaxyui_core::SingletonEntity;
if Self::is_private() {
<PrivatePreferences as SingletonEntity>::as_ref(ctx).deref()
} else if FeatureFlag::SettingsFile.is_enabled() {
<PublicPreferences as SingletonEntity>::as_ref(ctx).as_preferences()
} else {
// When the settings file is disabled, fall back to the private
// backend so both paths share a single instance.
<PrivatePreferences as SingletonEntity>::as_ref(ctx).deref()
}
}
/// Constructs a new instance of the setting, populating its initial value
/// based on any previously-stored value, falling back to
/// `Self::default_value()` if no value was stored or it could not be parsed
/// successfully.
fn new_from_storage(ctx: &mut AppContext) -> Self
where
Self: Sized,
{
Self::new(Self::read_from_preferences(Self::preferences_for_setting(
ctx,
)))
}
/// Reads the setting's value from the provided preferences, returning None
/// if the value is not set or could not be parsed successfully.
fn read_from_preferences(preferences: &dyn UserPreferences) -> Option<Self::Value> {
let key = if preferences.is_settings_file() {
Self::toml_key()
} else {
Self::storage_key()
};
let value = preferences
.read_value_with_hierarchy(key, Self::hierarchy())
.unwrap_or_default()?;
// For the settings file, use the SettingsValue trait.
if preferences.is_settings_file() {
let json_value = match serde_json::from_str::<serde_json::Value>(&value) {
Ok(v) => v,
Err(err) => {
log::error!(
"Failed to parse JSON for setting {}: {err:?}",
Self::storage_key()
);
preferences.inhibit_writes_for_key(Self::toml_key(), Self::hierarchy());
return None;
}
};
match <Self::Value as SettingsValue>::from_file_value(&json_value) {
Some(val) => {
log::debug!(
"Loaded {} from settings file; value: {:?}",
Self::setting_name(),
val
);
return Some(val);
}
None => {
log::error!(
"Failed to parse file value for setting {}",
Self::storage_key()
);
preferences.inhibit_writes_for_key(Self::toml_key(), Self::hierarchy());
return None;
}
}
}
match serde_json::from_str(&value) {
Ok(val) => {
log::debug!(
"Loaded {} from user defaults; value: {:?}",
Self::setting_name(),
val
);
Some(val)
}
Err(err) => {
log::error!(
"Failed to parse stored value for setting {}: {err:?}",
Self::storage_key()
);
None
}
}
}
/// Persists the current value of the setting in some form of durable
/// storage. Returns whether the value was changed from what was currently
/// stored.
fn write_to_preferences(
new_value: &Self::Value,
preferences: &dyn UserPreferences,
) -> Result<bool> {
let key = if preferences.is_settings_file() {
Self::toml_key()
} else {
Self::storage_key()
};
// For the settings file, use the SettingsValue trait.
let value = if preferences.is_settings_file() {
let file_value = <Self::Value as SettingsValue>::to_file_value(new_value);
serde_json::to_string(&file_value).context(format!(
"Failed to write {} to storage",
Self::storage_key()
))?
} else {
serde_json::to_string(new_value).context(format!(
"Failed to write {} to storage",
Self::storage_key()
))?
};
// Compare semantically by deserializing the stored value back into
// the typed value rather than comparing JSON strings. This avoids
// spurious writes caused by serialization differences (key ordering,
// null-vs-missing fields, formatting) that don't represent actual
// value changes.
let stored_value_matches = preferences
.read_value_with_hierarchy(key, Self::hierarchy())?
.as_deref()
.and_then(|stored| {
if preferences.is_settings_file() {
let json_value = serde_json::from_str::<serde_json::Value>(stored).ok()?;
return <Self::Value as SettingsValue>::from_file_value(&json_value);
}
serde_json::from_str::<Self::Value>(stored).ok()
})
.is_some_and(|stored_val| &stored_val == new_value);
if !stored_value_matches {
log::debug!(
"Writing new value of {} to storage; key: {}; value: {:?}",
Self::setting_name(),
key,
value
);
let _ = preferences.write_value_with_hierarchy(
key,
value,
Self::hierarchy(),
Self::max_table_depth(),
);
Ok(true)
} else {
Ok(false)
}
}
/// Clears the setting from the given durable storage and returns whether the setting was cleared.
fn clear_from_preferences(preferences: &dyn UserPreferences) -> Result<()> {
let key = if preferences.is_settings_file() {
Self::toml_key()
} else {
Self::storage_key()
};
log::debug!(
"Clearing setting {} with key {} from preferences",
Self::setting_name(),
key,
);
preferences.remove_value_with_hierarchy(key, Self::hierarchy())?;
Ok(())
}
/// Returns true if this setting is supported on the current platform (e.g., Web, Linux, Mac). For example,
/// Background opacity is supported on Mac and Linux, not Web.
fn is_supported_on_current_platform(&self) -> bool;
/// Returns true if this setting was explicitly set by the user (i.e., not using the default value).
fn is_value_explicitly_set(&self) -> bool;
}
/// Shared persistence operations for typed settings backed by secure storage.
///
/// Implementors remain responsible for routing their [`Setting`] lifecycle
/// methods through this trait and for keeping the setting private and
/// non-synced when the value must not be exposed through ordinary settings
/// storage.
pub trait SecureSetting: Setting {
/// Writes this setting's serialized value through its selected secure-storage path.
fn write_secure_storage_value(
storage: &dyn secure_storage::SecureStorage,
key: &str,
value: &str,
) -> Result<(), secure_storage::Error> {
storage.write_value(key, value)
}
/// Reads and deserializes this setting from secure storage.
///
/// Missing, unreadable, or malformed values return `None`, allowing the
/// setting to fail closed to its default value.
fn read_from_secure_storage(ctx: &AppContext) -> Option<Self::Value> {
let value = match ctx.secure_storage().read_value(Self::storage_key()) {
Ok(value) => value,
Err(secure_storage::Error::NotFound) => return None,
Err(err) => {
log::error!(
"Failed to read {} from secure storage: {err:#}",
Self::setting_name()
);
return None;
}
};
match serde_json::from_str(&value) {
Ok(value) => Some(value),
Err(err) => {
log::error!(
"Failed to deserialize {} from secure storage: {err:#}",
Self::setting_name()
);
None
}
}
}
/// Persists this setting to secure storage if its typed value changed.
fn write_to_secure_storage(new_value: &Self::Value, ctx: &AppContext) -> Result<bool> {
let stored_value_matches = match ctx.secure_storage().read_value(Self::storage_key()) {
Ok(stored) => serde_json::from_str::<Self::Value>(&stored)
.is_ok_and(|stored| stored == *new_value),
Err(secure_storage::Error::NotFound) => false,
Err(err) => {
return Err(anyhow::anyhow!(err)).context(format!(
"Failed to read existing {} from secure storage",
Self::setting_name()
));
}
};
if stored_value_matches {
return Ok(false);
}
let serialized = serde_json::to_string(new_value).context(format!(
"Failed to serialize {} for secure storage",
Self::setting_name()
))?;
Self::write_secure_storage_value(ctx.secure_storage(), Self::storage_key(), &serialized)
.context(format!(
"Failed to write {} to secure storage",
Self::setting_name()
))?;
Ok(true)
}
/// Removes this setting from secure storage.
fn clear_from_secure_storage(ctx: &AppContext) -> Result<()> {
match ctx.secure_storage().remove_value(Self::storage_key()) {
Ok(()) | Err(secure_storage::Error::NotFound) => Ok(()),
Err(err) => Err(anyhow::anyhow!(err)).context(format!(
"Failed to clear {} from secure storage",
Self::setting_name()
)),
}
}
}
/// A trait for settings that can be toggled between two values.
pub trait ToggleableSetting: Setting {
/// Toggles the value of the setting and persists it to storage, returning
/// the new value upon success.
fn toggle_and_save_value(
&mut self,
ctx: &mut ModelContext<<Self as Setting>::Group>,
) -> Result<<Self as Setting>::Value>;
}
impl<T, S> ToggleableSetting for S
where
T: std::ops::Not<Output = T> + Copy + Debug,
S: Setting<Value = T>,
{
fn toggle_and_save_value(
&mut self,
ctx: &mut ModelContext<<Self as Setting>::Group>,
) -> Result<<Self as Setting>::Value> {
let current_value = *self.value();
let new_value = !current_value;
log::debug!(
"Toggling value of {} from {:?} to {:?}",
Self::setting_name(),
current_value,
new_value
);
self.set_value(new_value, ctx)?;
Ok(new_value)
}
}
#[cfg(test)]
#[path = "toml_path_tests.rs"]
mod toml_path_tests;
#[cfg(test)]
#[path = "mod_tests.rs"]
mod mod_tests;