Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
+5
View File
@@ -0,0 +1,5 @@
#[cfg(feature = "secure_storage")]
pub mod secure_storage;
#[cfg(feature = "user_preferences")]
pub mod user_preferences;
@@ -0,0 +1,345 @@
//! Implementation of the [`SecureStorage`] service for the Linux platform.
use std::{cell::OnceCell, collections::HashMap, path::PathBuf};
use anyhow::{anyhow, Context};
use rand::RngCore;
use ring::aead;
use secret_service::{
blocking::{Item, SecretService},
EncryptionType,
};
use super::Error;
/// Implementation of the SecureStorage service using the Secret Service API.
pub struct SecureStorage {
/// The value to set for the "service" attribute, used to define a
/// namespace for keys for the application.
service_name: String,
/// A lazily-initialized reference to the default secret collection as
/// provided by the installed Secret Service API provider.
collection: OnceCell<Option<Collection>>,
/// The fallback path to a directory in case a secret collection is
/// not available.
fallback_dir: Option<PathBuf>,
/// The encryption fallback key.
encryption_key: OnceCell<Option<aead::LessSafeKey>>,
}
impl SecureStorage {
/// Creates a new [`SecureStorage`] instance.
///
/// This does not eagerly open a connection to dbus or the underlying
/// Secret Service provider.
pub fn new(service_name: &str) -> Self {
Self {
service_name: service_name.to_owned(),
collection: OnceCell::new(),
fallback_dir: None,
encryption_key: OnceCell::new(),
}
}
/// Creates a new [`SecureStorage`] instance with disk fallback
///
/// Does the same work as [`SecureStorage::new`], as well as storing
/// a path to a fallback directory.
pub fn new_with_fallback(service_name: &str, fallback_dir: PathBuf) -> Self {
Self {
service_name: service_name.to_owned(),
collection: OnceCell::new(),
fallback_dir: Some(fallback_dir),
encryption_key: OnceCell::new(),
}
}
/// Returns a reference to the default secret collection, lazily
/// instantiating the underlying service and collection reference,
/// returning an error if the connection cannot be established or the
/// collection cannot be opened.
///
/// TODO(vorporeal): Decide whether we want to "cache" a failed connection
/// or instead only store the collection on a successful connection and
/// return the error on an initialization failure.
fn collection(&self) -> Result<&secret_service::blocking::Collection<'_>, Error> {
self.collection
.get_or_init(|| match Collection::open_default_collection() {
Ok(collection) => Some(collection),
Err(err) => {
log::error!("Failed to acquire default Secret Service collection: {err:#}");
None
}
})
.as_ref()
.ok_or_else(|| {
Error::Unknown(anyhow!("Failed to initialize Secret Service connection"))
})
.and_then(|collection| {
let collection = collection.borrow_collection();
// Ensure that the collection is unlocked, otherwise we will be unable
// add or modify items.
collection.unlock()?;
Ok(collection)
})
}
/// Returns an encryption key that can be used to encrypt
/// values if the default secret collection is not available
/// or otherwise not working. The key is lazy initialized since
/// it does not need to be created unless the main method of
/// storing secrets has failed
fn encryption_key(&self) -> Result<&aead::LessSafeKey, Error> {
self.encryption_key
.get_or_init(|| {
// We can use whatever super duper foolproof secure key we want here.
// Here we are specifically choosing a value that will look inconspicuous
// in case someone chooses to scan our binary for strings.
let mut key_bytes = Vec::from("https://releases.warp.dev/channel_versions.json");
key_bytes.resize(aead::AES_256_GCM.key_len(), 0);
match aead::UnboundKey::new(&aead::AES_256_GCM, key_bytes.as_slice()) {
Ok(key) => Some(aead::LessSafeKey::new(key)),
Err(_) => {
log::error!("Failed to initialize fallback encryption key");
None
}
}
})
.as_ref()
.ok_or_else(|| Error::Unknown(anyhow!("Invalid encryption key")))
}
/// Returns the set of attributes which should be used when interacting
/// with a secret item that is identified by the given key.
fn attributes_for_key<'a>(&'a self, key: &'a str) -> HashMap<&'static str, &'a str> {
HashMap::from([
// Ensure our keys don't conflict with ones stored by another
// application.
("service", self.service_name.as_str()),
// Specify the key for the secret.
("key", key),
])
}
/// Provides the given function access to a secret item with the given key
/// in order to read or manipulate the item.
fn with_item<T>(
&self,
key: &str,
func: impl FnOnce(&Item) -> Result<T, Error>,
) -> Result<T, Error> {
let collection = self.collection()?;
let items = collection.search_items(self.attributes_for_key(key))?;
let Some(item) = items.first() else {
return Err(Error::NotFound);
};
func(item)
}
fn write_secret_value(&self, key: &str, value: &str) -> Result<(), Error> {
let collection = self.collection()?;
// Construct a slightly more human-readable label for the secret than
// using the key alone.
let label = format!("{}: {key}", self.service_name);
collection.create_item(
&label,
self.attributes_for_key(key),
value.as_bytes(),
// replace the existing key, if any
true,
"text/plain",
)?;
Ok(())
}
fn fallback_encrypt(&self, value: &str) -> Result<Vec<u8>, Error> {
let encryption_key = self.encryption_key()?;
// Generates nonce by randomly generating numbers
// This is not the official best way to do this, but it should
// be fine for our purposes.
let mut rng = rand::thread_rng();
let mut nonce_bytes = [0u8; aead::NONCE_LEN];
rng.fill_bytes(&mut nonce_bytes);
let nonce = aead::Nonce::assume_unique_for_key(nonce_bytes);
let mut data = value.as_bytes().to_vec();
encryption_key
.seal_in_place_append_tag(nonce, aead::Aad::empty(), &mut data)
.map_err(Into::<Error>::into)
.context("Fallback encryption failed")?;
// We serialize this to disk as the 12 byte nonce followed by the message.
let mut output = Vec::<u8>::with_capacity(aead::NONCE_LEN + data.len());
output.extend_from_slice(&nonce_bytes);
output.append(&mut data);
Ok(output)
}
fn fallback_decrypt(&self, value: &[u8]) -> Result<String, Error> {
if value.len() < aead::NONCE_LEN + 1 {
return Err(Error::Unknown(anyhow!(
"Attempting to decrypt too small value for fallback decryption"
)));
}
let encryption_key = self.encryption_key()?;
// The first 12 bytes of the message are the nonce.
let nonce_bytes = &value[0..aead::NONCE_LEN];
let nonce = aead::Nonce::try_assume_unique_for_key(nonce_bytes)
.map_err(Into::<Error>::into)
.context("Failed to parse nonce for fallback decryption")?;
// The remaining bytes in the message are the data.
// We convert this to owned b/c the decryption happens in place.
let mut data_bytes = value[aead::NONCE_LEN..].to_owned();
let decrypted_length = encryption_key
.open_in_place(nonce, aead::Aad::empty(), &mut data_bytes)
.map_err(Into::<Error>::into)
.context("Fallback decryption failed")?
.len();
// The decryption happens in place, but does not resize the vec.
// Meanwhile, a slice referring to the decrypted data is returned.
// We use the length of that slice to resize the currently owned Vec,
// so it can be consumed by String::from_utf8 later on.
data_bytes.resize(decrypted_length, 0);
String::from_utf8(data_bytes).map_err(|err| Error::DecodeError(err.utf8_error()))
}
fn fallback_file(&self, key: &str) -> Result<PathBuf, Error> {
let Some(dir) = &self.fallback_dir else {
return Err(Error::NotFound);
};
let filename = format!("{}-{key}", self.service_name);
let mut path = dir.clone();
path.push(filename);
Ok(path)
}
fn write_fallback_value(&self, key: &str, value: &str) -> Result<(), Error> {
let fallback_file = self.fallback_file(key)?;
let encrypted = self.fallback_encrypt(value)?;
std::fs::write(fallback_file, encrypted).map_err(|err| Error::Unknown(err.into()))
}
fn read_fallback_value(&self, key: &str) -> Result<String, Error> {
let fallback_file = self.fallback_file(key)?;
let data = std::fs::read(fallback_file).map_err(|_| Error::NotFound)?;
self.fallback_decrypt(&data)
}
fn delete_fallback_value(&self, key: &str) -> Result<(), Error> {
let fallback_file = self.fallback_file(key)?;
std::fs::remove_file(fallback_file).map_err(|err| match err {
ref io_error if io_error.kind() == std::io::ErrorKind::NotFound => Error::NotFound,
io_error => Error::Unknown(io_error.into()),
})
}
}
impl super::SecureStorage for SecureStorage {
fn write_value(&self, key: &str, value: &str) -> Result<(), Error> {
let secret_result = self.write_secret_value(key, value);
match secret_result {
Ok(_) => {
// If we are able to write the secret value, we attempt to delete any fallback values
let _ = self.delete_fallback_value(key);
Ok(())
}
Err(_) => self.write_fallback_value(key, value),
}
}
fn read_value(&self, key: &str) -> Result<String, Error> {
let secret_result = self.with_item(key, |item| {
let bytes = item.get_secret()?;
String::from_utf8(bytes).map_err(|err| Error::DecodeError(err.utf8_error()))
});
match secret_result {
Ok(value) => {
// If we are able to read the secret value, we attempt to delete any fallback values
let _ = self.delete_fallback_value(key);
Ok(value)
}
// TODO(daprahamian): We might want to filter on specific error values, rather than all errors
Err(_) => self.read_fallback_value(key),
}
}
fn remove_value(&self, key: &str) -> Result<(), Error> {
let secret_result = self.with_item(key, |item| item.delete().map_err(Into::into));
let fs_result = self.delete_fallback_value(key);
// We delete both the value in the secret store and the fallback values.
// As long as one succeeds, we consider the delete a success.
match (secret_result, fs_result) {
(Err(secret_err), Err(_)) => Err(secret_err),
_ => Ok(()),
}
}
}
impl From<secret_service::Error> for Error {
fn from(value: secret_service::Error) -> Self {
// TODO(vorporeal): Check to see if we can return any more specific
// values.
Error::Unknown(anyhow!(value))
}
}
impl From<ring::error::Unspecified> for Error {
fn from(value: ring::error::Unspecified) -> Self {
Error::Unknown(anyhow!(value))
}
}
/// A helper structure that maintains access to the default collection.
///
/// [`secret_service::SecretService`] is a self-referential struct that leaks
/// its internal reference lifetime, which is why we use [`ouroboros`] here to
/// provide a safe API for interacting with the service and collection.
#[ouroboros::self_referencing]
struct Collection {
/// An encrypted dbus connection to the Secret Service API provider.
#[borrows()]
#[covariant]
service: SecretService<'this>,
/// A reference to the default secret collection, which can be used to
/// add, remove and read secrets.
#[borrows(service)]
#[covariant]
collection: secret_service::blocking::Collection<'this>,
}
impl Collection {
/// Tries to open the default secret collection via the Secret Service
/// API.
fn open_default_collection() -> Result<Self, Error> {
SecretService::connect(EncryptionType::Plain)
.and_then(|service| {
CollectionTryBuilder {
service,
collection_builder: |service| service.get_default_collection(),
}
.try_build()
})
.map_err(Into::into)
}
}
#[cfg(test)]
#[path = "linux_test.rs"]
mod tests;
@@ -0,0 +1,46 @@
use super::Error;
use super::SecureStorage;
#[test]
fn test_encrypt_decrypt_returns_same_value() {
let storage = SecureStorage::new("darmok");
let input = String::from("darmok and jalad at tanagra");
let encrypted = storage.fallback_encrypt(&input).unwrap();
let output = storage.fallback_decrypt(&encrypted).unwrap();
assert_eq!(input, output)
}
#[test]
fn test_encrypt_decrypt_works_across_storage_instances() {
let storage_1 = SecureStorage::new("darmok");
let storage_2 = SecureStorage::new("jalad");
let input = String::from("shaka when the walls fell");
let encrypted = storage_1.fallback_encrypt(&input).unwrap();
let output = storage_2.fallback_decrypt(&encrypted).unwrap();
assert_eq!(input, output)
}
#[test]
fn test_decrypt_fails_on_malformed_data() {
let storage = SecureStorage::new("darmok");
let bad_datas: [&[u8]; 4] = [&[], &[0; 1], &[0; 11], &[0; 12]];
for bad_data in bad_datas {
let result = storage.fallback_decrypt(bad_data);
assert!(result.is_err());
let error = result.unwrap_err();
let Error::Unknown(err) = error else {
panic!("Expected error variant to be Error::Unknown, but found {error:?}")
};
assert_eq!(
format!("{err}"),
"Attempting to decrypt too small value for fallback decryption"
);
}
}
@@ -0,0 +1,62 @@
//! Implementations of the [`SecureStorage`] service for the macOS platform.
use anyhow::anyhow;
use security_framework::os::macos::{
keychain::SecKeychain, keychain_item::SecKeychainItem, passwords::SecKeychainItemPassword,
};
use super::Error;
/// Implementation of the SecureStorage service using macOS Security
/// framework keychains.
pub struct SecureStorage {
/// The name of the service under which to store the values.
service_name: String,
}
impl SecureStorage {
pub fn new(service_name: &str) -> Self {
Self {
service_name: service_name.to_owned(),
}
}
}
impl super::SecureStorage for SecureStorage {
fn write_value(&self, key: &str, value: &str) -> Result<(), Error> {
let keychain = SecKeychain::default()?;
keychain
.set_generic_password(self.service_name.as_str(), key, value.as_bytes())
.map_err(Into::into)
}
fn read_value(&self, key: &str) -> Result<String, Error> {
let (password, _) = self.get_password_item(key)?;
String::from_utf8(password.as_ref().to_vec())
.map_err(|err| Error::DecodeError(err.utf8_error()))
}
fn remove_value(&self, key: &str) -> Result<(), Error> {
let (_, item) = self.get_password_item(key)?;
item.delete();
Ok(())
}
}
impl SecureStorage {
fn get_password_item(
&self,
key: &str,
) -> Result<(SecKeychainItemPassword, SecKeychainItem), Error> {
let keychain = SecKeychain::default()?;
keychain
.find_generic_password(&self.service_name, key)
.map_err(|_| Error::NotFound)
}
}
impl From<security_framework::base::Error> for Error {
fn from(value: security_framework::base::Error) -> Self {
Error::Unknown(anyhow!(value))
}
}
@@ -0,0 +1,184 @@
//! Secure storage for passwords and other application secrets.
//!
//! This defines an API for interacting with an underlying secure storage
//! system, implementations of the API for various platforms, testing
//! utilities, and extension traits to improve ergonomics of using the APIs.
#[cfg(not(target_family = "wasm"))]
#[cfg_attr(target_os = "macos", path = "mac.rs")]
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
mod imp;
mod noop;
// Treat this as a noop on web, as there is no backing storage which is "secure".
#[cfg(target_family = "wasm")]
use noop as imp;
#[cfg(target_os = "windows")]
mod windows_only {
pub(super) use std::string::FromUtf8Error;
}
#[cfg(target_os = "windows")]
use windows_only::*;
/// A type alias for the concrete type stored within a galaxyui
/// app context, enabling usage such as:
///
/// ```
/// use galaxyui::{App, SingletonEntity};
/// use galaxyui_extras::secure_storage;
///
/// App::test((), |mut app| async move {
/// app.update(|ctx| {
/// #[cfg(not(windows))]
/// secure_storage::register("service_name", ctx);
/// #[cfg(windows)]
/// secure_storage::register_with_dir("service_name", std::path::PathBuf::from(r"C:\some\path"), ctx);
///
/// let _ = secure_storage::Model::handle(ctx).as_ref(ctx).read_value("some_key");
/// });
/// });
/// ```
/// Note that the above rustdoc example is `ignore`d in compilation
/// due to API differences across platforms.
pub type Model = Box<dyn SecureStorage>;
/// Registers a platform-native Secure Storage provider with the application.
///
/// The service name is used as a namespace for the application's secrets. It
/// is recommended that this be a unique identifier for the application; one
/// common scheme is reverse-DNS notation (e.g.: "dev.warp.Warp").
#[cfg(not(target_os = "windows"))]
pub fn register(service_name: &str, ctx: &mut galaxyui::AppContext) {
ctx.add_singleton_model(|_| -> Model { Box::new(imp::SecureStorage::new(service_name)) });
}
/// Registers a no-op Secure Storage provider with the application.
pub fn register_noop(service_name: &str, ctx: &mut galaxyui::AppContext) {
ctx.add_singleton_model(|_| -> Model { Box::new(noop::SecureStorage::new(service_name)) });
}
#[cfg(target_os = "linux")]
pub fn register_with_fallback(
service_name: &str,
fallback_dir: std::path::PathBuf,
ctx: &mut galaxyui::AppContext,
) {
ctx.add_singleton_model(|_| -> Model {
Box::new(imp::SecureStorage::new_with_fallback(
service_name,
fallback_dir,
))
});
}
/// Registers a Windows-native Secure Storage provider
/// that uses the provided directory to store data in encrypted files.
#[cfg(target_os = "windows")]
pub fn register_with_dir(
service_name: &str,
storage_dir: std::path::PathBuf,
ctx: &mut galaxyui::AppContext,
) {
ctx.add_singleton_model(|_| -> Model {
Box::new(imp::SecureStorage::new_with_path(service_name, storage_dir))
});
}
/// A trait representing a secure store for key-value pairs.
///
/// This is typically backed by an OS-provided secure storage system.
pub trait SecureStorage {
/// Writes a value at the given key.
fn write_value(&self, key: &str, value: &str) -> Result<(), Error>;
/// Reads the value stored at the given key.
fn read_value(&self, key: &str) -> Result<String, Error>;
/// Removes the value stored at the given key, if any.
fn remove_value(&self, key: &str) -> Result<(), Error>;
}
impl galaxyui::Entity for Model {
type Event = ();
}
impl galaxyui::SingletonEntity for Model {}
/// Enumerates the various errors that can occur when interacting with secure
/// storage.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The item with the given key was not found in secure storage.
///
/// This is not guaranteed to be returned in all cases where the item is
/// not found; if we are not able to interpret the error returned by the
/// underlying implementation, [`SecureStorageError::Unknown`] may be
/// returned.
#[error("item not found")]
NotFound,
/// Failed to decode the stored bytes into a UTF-8 string.
#[error("failed to decode UTF-8 string from bytes")]
DecodeError(#[from] std::str::Utf8Error),
/// Encountered an error when reading to or from a file.
#[cfg(windows)]
#[error("File I/O error")]
IOError(#[from] std::io::Error),
/// An error was encountered while using the windows CryptProtect API.
#[cfg(windows)]
#[error("Windows CryptProtect API error")]
WindowsAPIError(#[from] windows::core::Error),
/// The provided secure storage directory path was not valid.
#[cfg(windows)]
#[error("Invalid secure storage location")]
InvalidLocation,
/// Catch-all for unclassifiable errors.
#[error("unknown error")]
Unknown(#[from] anyhow::Error),
}
#[cfg(windows)]
impl From<FromUtf8Error> for Error {
fn from(value: FromUtf8Error) -> Self {
Self::DecodeError(value.utf8_error())
}
}
/// An extension trait to make secure storage easier to use.
///
/// ```
/// use galaxyui::{App, SingletonEntity};
/// use galaxyui_extras::secure_storage;
///
/// App::test((), |mut app| async move {
/// app.update(|ctx| {
/// #[cfg(not(windows))]
/// secure_storage::register("service_name", ctx);
/// #[cfg(windows)]
/// secure_storage::register_with_dir("service_name", std::path::PathBuf::from(r"C:\some\path"), ctx);
///
/// use secure_storage::AppContextExt;
/// let _ = ctx.secure_storage().read_value("some_key");
/// });
/// });
/// ```
/// Note that the above rustdoc example is `ignore`d in compilation
/// due to API differences across platforms.
pub trait AppContextExt {
fn secure_storage(&self) -> &dyn SecureStorage;
}
impl AppContextExt for galaxyui::AppContext {
fn secure_storage(&self) -> &dyn SecureStorage {
use galaxyui::SingletonEntity;
<Model as SingletonEntity>::as_ref(self).as_ref()
}
}
@@ -0,0 +1,26 @@
//! No-op [`SecureStorage`] service for use in unit and integration tests.
use super::Error;
#[derive(Default)]
pub struct SecureStorage {}
impl SecureStorage {
pub fn new(_service_name: &str) -> Self {
Self {}
}
}
impl super::SecureStorage for SecureStorage {
fn write_value(&self, _key: &str, _value: &str) -> Result<(), Error> {
Ok(())
}
fn read_value(&self, _key: &str) -> Result<String, Error> {
Ok("".to_string())
}
fn remove_value(&self, _key: &str) -> Result<(), Error> {
Ok(())
}
}
@@ -0,0 +1,109 @@
use std::path::PathBuf;
use windows::{
core::BSTR,
Win32::{
Foundation::{LocalFree, HLOCAL},
Security::Cryptography::{CryptProtectData, CryptUnprotectData, CRYPT_INTEGER_BLOB},
},
};
use super::Error;
#[derive(Default)]
pub struct SecureStorage {
service_name: String,
storage_dir: PathBuf,
}
impl SecureStorage {
pub fn new_with_path(service_name: &str, storage_dir: PathBuf) -> Self {
Self {
service_name: service_name.to_string(),
storage_dir,
}
}
fn storage_file(&self, key: &str) -> PathBuf {
let filename = format!("{}-{key}", self.service_name);
self.storage_dir.join(filename)
}
fn byte_vec_to_blob(byte_vec: &mut Vec<u8>) -> CRYPT_INTEGER_BLOB {
let byte_slice = byte_vec.as_mut_slice();
CRYPT_INTEGER_BLOB {
cbData: byte_slice.len() as u32,
pbData: byte_slice.as_mut_ptr(),
}
}
fn encrypt(key: &str, mut plaintext: String) -> Result<Vec<u8>, Error> {
let mut encrypted_blob = CRYPT_INTEGER_BLOB::default();
let encrypted_bytes = unsafe {
let plaintext_bytes = plaintext.as_bytes_mut();
let plaintext_blob = CRYPT_INTEGER_BLOB {
cbData: plaintext_bytes.len() as u32,
pbData: plaintext_bytes.as_mut_ptr(),
};
CryptProtectData(
&plaintext_blob,
&BSTR::from(key),
None,
None,
None,
0,
&mut encrypted_blob,
)?;
let encrypted_bytes =
std::slice::from_raw_parts(encrypted_blob.pbData, encrypted_blob.cbData as usize)
.to_vec();
LocalFree(Some(HLOCAL(encrypted_blob.pbData.cast())));
encrypted_bytes
};
Ok(encrypted_bytes)
}
fn decrypt(mut encrypted_bytes: Vec<u8>) -> Result<String, Error> {
let encrypted_blob = Self::byte_vec_to_blob(&mut encrypted_bytes);
let mut decrypted_blob = CRYPT_INTEGER_BLOB::default();
let decrypted_bytes = unsafe {
CryptUnprotectData(
&encrypted_blob,
None,
None,
None,
None,
0,
&mut decrypted_blob,
)?;
let byte_vec =
std::slice::from_raw_parts(decrypted_blob.pbData, decrypted_blob.cbData as usize)
.to_vec();
LocalFree(Some(HLOCAL(decrypted_blob.pbData.cast())));
byte_vec
};
Ok(String::from_utf8(decrypted_bytes)?)
}
}
impl super::SecureStorage for SecureStorage {
fn write_value(&self, key: &str, value: &str) -> Result<(), Error> {
let storage_file = self.storage_file(key);
let encrypted_bytes = Self::encrypt(key, value.to_string())?;
std::fs::write(storage_file, encrypted_bytes).map_err(Error::from)
}
fn read_value(&self, key: &str) -> Result<String, Error> {
let storage_file = self.storage_file(key);
let file_bytes = std::fs::read(storage_file)?;
Self::decrypt(file_bytes)
}
fn remove_value(&self, key: &str) -> Result<(), Error> {
let storage_file = self.storage_file(key);
std::fs::remove_file(storage_file).map_err(Error::from)
}
}
#[cfg(test)]
#[path = "windows_test.rs"]
mod test;
@@ -0,0 +1,30 @@
use super::SecureStorage;
#[test]
fn test_encrypt_decrypt_returns_same_value() {
let key = String::from("key");
let inputs = [
"freckles grain uncaring strict stumbling reappear basil uproar",
"ideology shifting overview cognition uniformed armory mummify editor",
"",
"{",
"\'",
"\"",
"{\"test\"}",
"defender french skating sweat neurotic extruding cadet mute headcount unaligned prognosis heroics geography deafening customer juicy scuttle blissful scrambler spleen embark engine shield banter botanist singing plutonium grafted carton playable approve astonish",
"{\"id_token\":{\"id_token\":\"This is an ID token.\",\"refresh_token\":\"This is a refresh token.\",\"expiration_time\":\"2025-10-22T15:26:51.091844800-04:00\"},\"refresh_token\":\"\",\"local_id\":\"test_user_uid\",\"email\":\"test_user@warp.dev\",\"display_name\":\"abcdef\",\"photo_url\":\"some-photo-url=\",\"is_onboarded\":true,\"needs_sso_link\":false,\"anonymous_user_type\":null,\"expires_at\":null,\"linked_at\":null,\"is_guaranteed_expired\":false,\"is_on_work_domain\":false}",
].map(String::from);
fn encrypt_then_decrypt(key: &str, input: String) -> String {
let encrypted = SecureStorage::encrypt(key, input).unwrap();
SecureStorage::decrypt(encrypted).unwrap()
}
for input in inputs {
assert_eq!(
encrypt_then_decrypt(&key, input.to_owned()),
input,
"Encrypting and decrypting {input:?}"
);
}
}
@@ -0,0 +1,94 @@
//! Implementation of the [`UserPreferences`] trait using a file for
//! persistence.
use std::path::{Path, PathBuf};
use super::{in_memory::InMemoryPreferences, Error};
/// An implementation of the [`UserPreferences`] trait using a file for
/// persistence.
///
/// Note that this is currently not robust to external modifications to
/// the backing file (either by another instance of the application or
/// by an end user). This reads the file once when initialized and keeps
/// an in-memory copy of the preferences, flushing to disk after each
/// update.
pub struct FileBackedUserPreferences {
/// The path to the file that backs this preferences store.
file_path: PathBuf,
/// A backing in-memory preferences store that we can flush to disk upon
/// modification.
inner: InMemoryPreferences,
}
impl FileBackedUserPreferences {
/// Constructs a new file-backed user preferences store.
///
/// If no file exists at the given path, an empty in-memory backing store
/// will be used, and any modifications will trigger creation of the file
/// (including any missing parent directories).
///
/// Returns an error if something went wrong while attempting to read the
/// existing persisted preferences at the given path.
pub fn new(file_path: PathBuf) -> Result<Self, Error> {
let inner = Self::initialize_in_memory_preferences(file_path.as_path())?;
Ok(Self { file_path, inner })
}
/// Loads the contents of the file at the given path into an in-memory
/// preferences store.
///
/// If the file is not found, an empty store is returned. The file is
/// not created.
fn initialize_in_memory_preferences(file_path: &Path) -> Result<InMemoryPreferences, Error> {
let file_contents = match std::fs::read_to_string(file_path) {
Ok(file_contents) => file_contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(InMemoryPreferences::default());
}
Err(err) => return Err(err.into()),
};
// If the file is empty/only whitespace, proceed with the default preferences.
if file_contents.trim().is_empty() {
return Ok(InMemoryPreferences::default());
}
let prefs = serde_json::from_str(&file_contents).map_err(anyhow::Error::new);
if let Err(err) = &prefs {
log::warn!("Failed to deserialize file preferences: {err:#}");
}
Ok(prefs?)
}
/// Flushes the internal in-memory preferences store to disk.
fn flush(&self) -> Result<(), Error> {
let parent_dir = self
.file_path
.parent()
.expect("absolute path to file should have parent");
std::fs::create_dir_all(parent_dir)?;
let data = serde_json::to_string_pretty(&self.inner).map_err(anyhow::Error::new)?;
std::fs::write(&self.file_path, data)?;
Ok(())
}
}
impl super::UserPreferences for FileBackedUserPreferences {
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
self.inner.write_value(key, value)?;
self.flush()
}
fn read_value(&self, key: &str) -> Result<Option<String>, super::Error> {
self.inner.read_value(key)
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
self.inner.remove_value(key)?;
self.flush()
}
}
@@ -0,0 +1,25 @@
use std::{cell::RefCell, collections::HashMap};
use serde::{Deserialize, Serialize};
/// A non-persisted, memory-backed user preferences store.
#[derive(Default, Serialize, Deserialize)]
pub struct InMemoryPreferences {
prefs: RefCell<HashMap<String, String>>,
}
impl super::UserPreferences for InMemoryPreferences {
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
self.prefs.borrow_mut().insert(key.to_owned(), value);
Ok(())
}
fn read_value(&self, key: &str) -> Result<Option<String>, super::Error> {
Ok(self.prefs.borrow().get(key).map(ToOwned::to_owned))
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
let _ = self.prefs.borrow_mut().remove(key);
Ok(())
}
}
@@ -0,0 +1,30 @@
use super::UserPreferences;
use gloo_storage::{errors::StorageError, LocalStorage, Storage};
/// An implementation of the [`UserPreferences`] trait using the local storage
/// property of the Web Storage API for persistence.
///
/// See: https://developer.mozilla.org/en-US/docs/Web/API/Storage
#[derive(Default)]
pub struct LocalStoragePreferences;
impl UserPreferences for LocalStoragePreferences {
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
LocalStorage::set(key, value)
.map_err(anyhow::Error::from)
.map_err(super::Error::from)
}
fn read_value(&self, key: &str) -> Result<Option<String>, super::Error> {
match LocalStorage::get(key) {
Ok(val) => Ok(Some(val)),
Err(StorageError::KeyNotFound(_)) => Ok(None),
Err(e) => Err(super::Error::from(anyhow::Error::from(e))),
}
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
LocalStorage::delete(key);
Ok(())
}
}
@@ -0,0 +1,132 @@
//! Storage for user preferences.
pub mod file_backed;
pub mod in_memory;
#[cfg(target_family = "wasm")]
pub mod local_storage;
#[cfg(target_os = "windows")]
pub mod registry_backed;
#[cfg(feature = "user_preferences-toml")]
pub mod toml_backed;
#[cfg(target_os = "macos")]
pub mod user_defaults;
/// A type alias for a boxed user preferences backend.
pub type Model = Box<dyn UserPreferences>;
/// A trait representing storage for user preferences.
pub trait UserPreferences {
/// Writes a value at the given key.
fn write_value(&self, key: &str, value: String) -> Result<(), Error>;
/// Reads the value stored at the given key.
///
/// Returns Ok(None) if no value was found.
fn read_value(&self, key: &str) -> Result<Option<String>, Error>;
/// Removes the value stored at the given key, if any.
fn remove_value(&self, key: &str) -> Result<(), Error>;
/// Writes a value at the given key, with optional hierarchy context.
///
/// Hierarchy-aware backends (like TOML) use the hierarchy to place the
/// value in the correct section. The default implementation ignores
/// the hierarchy and delegates to [`write_value`](Self::write_value).
///
/// `max_table_depth` controls how deeply nested objects are rendered as
/// section tables before switching to inline tables:
/// - `None` — unlimited depth (all section tables)
/// - `Some(0)` — fully inline (`key = { ... }`)
/// - `Some(n)` — `n` levels of section tables, then inline
fn write_value_with_hierarchy(
&self,
key: &str,
value: String,
hierarchy: Option<&str>,
max_table_depth: Option<u32>,
) -> Result<(), Error> {
let _ = (hierarchy, max_table_depth);
self.write_value(key, value)
}
/// Reads the value stored at the given key, with optional hierarchy context.
///
/// The default implementation ignores the hierarchy and delegates to
/// [`read_value`](Self::read_value).
fn read_value_with_hierarchy(
&self,
key: &str,
hierarchy: Option<&str>,
) -> Result<Option<String>, Error> {
let _ = hierarchy;
self.read_value(key)
}
/// Removes the value stored at the given key, with optional hierarchy context.
///
/// The default implementation ignores the hierarchy and delegates to
/// [`remove_value`](Self::remove_value).
fn remove_value_with_hierarchy(&self, key: &str, hierarchy: Option<&str>) -> Result<(), Error> {
let _ = hierarchy;
self.remove_value(key)
}
/// Returns whether this backend is the user-visible settings file.
///
/// When true, settings that define custom file serialization (via
/// `file_serialize` / `file_deserialize`) will use their custom format
/// instead of the standard serde representation. This produces a more
/// human-readable settings file.
///
/// Other backends (NSUserDefaults, in-memory, etc.) return `false` and
/// always use the standard serde format.
fn is_settings_file(&self) -> bool {
false
}
/// Reloads the backing store from disk.
///
/// File-backed backends re-read their file and replace the in-memory
/// contents. Non-file backends do nothing. On parse failure the
/// implementation should keep the previous state and return an error.
fn reload_from_disk(&self) -> Result<(), Error> {
Ok(())
}
/// Marks a key as write-inhibited so that subsequent writes and removes
/// for this key are silently skipped.
///
/// This is used to protect individual setting values that exist in the
/// backing store but could not be deserialized into the expected type.
/// The user's broken-but-fixable value is preserved until they correct
/// it in the file.
///
/// The default implementation is a no-op (non-file backends don't need
/// per-key inhibition).
fn inhibit_writes_for_key(&self, key: &str, hierarchy: Option<&str>) {
let _ = (key, hierarchy);
}
/// Clears all per-key write inhibitions.
///
/// Called after a successful reload from disk so that inhibitions can
/// be re-derived from the freshly loaded values.
fn clear_all_write_inhibitions(&self) {}
}
/// Enumerates the various errors that can occur when interacting with user
/// preferences.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Failed to decode the stored bytes into a UTF-8 string.
#[error("failed to decode UTF-8 string from bytes")]
DecodeError(#[from] std::str::Utf8Error),
/// Generic I/O error.
#[error("i/o error")]
IoError(#[from] std::io::Error),
/// Catch-all for unclassifiable errors.
#[error("unknown error")]
Unknown(#[from] anyhow::Error),
}
@@ -0,0 +1,52 @@
use std::io;
/// Store user preferences in the Windows Registry.
/// Modeled after https://github.com/neovide/neovide/blob/main/src/windows_utils.rs .
use super::UserPreferences;
use windows_registry::{Key, CURRENT_USER};
use windows_result::HRESULT;
pub struct RegistryBackedPreferences {
app_key_path: String,
}
static WARP_REGISTRY_BASE_PATH: &str = "Software\\Warp.dev\\";
pub const KEY_NOT_FOUND_ERR: HRESULT = HRESULT::from_win32(0x80070002);
impl RegistryBackedPreferences {
/// Construct a separate registry path for each channel (stable, dev, local, etc.)
pub fn new(app_name: &str) -> Self {
Self {
app_key_path: WARP_REGISTRY_BASE_PATH.to_owned() + app_name,
}
}
/// Gets Warp's registry key, creating it if it does not already exist.
fn get_warp_registry(&self) -> Result<Key, super::Error> {
CURRENT_USER.create(self.app_key_path.clone()).map_err(|e| {
log::error!("unable to access Warp app key in Windows Registry: {e:#}");
super::Error::IoError(io::Error::from(e))
})
}
}
impl UserPreferences for RegistryBackedPreferences {
fn read_value(&self, name: &str) -> Result<Option<String>, super::Error> {
Ok(self.get_warp_registry()?.get_string(name).ok())
}
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
self.get_warp_registry()?
.set_string(key, value.as_str())
.map_err(|e| super::Error::from(io::Error::from(e)))
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
match self.get_warp_registry()?.remove_value(key) {
Ok(_) => Ok(()),
// If the key doesn't exist, then treat removal of that nonexistent key as a success.
Err(e) if e.code() == KEY_NOT_FOUND_ERR => Ok(()),
Err(e) => Err(super::Error::from(io::Error::from(e))),
}
}
}
@@ -0,0 +1,663 @@
//! Implementation of the [`UserPreferences`] trait using a TOML file for
//! persistence, with support for hierarchical sections and snake_case keys.
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, Value};
use super::Error;
/// Indentation used per nesting level when pretty-printing multi-line arrays
/// and inline tables.
const INDENT: &str = " ";
/// Rough line-length budget before an inline container breaks across lines.
///
/// Arrays and inline tables whose default single-line rendering exceeds this
/// width get pretty-printed across multiple lines. This is measured against
/// the longest line of the container's current rendering, so already-broken
/// children don't defeat the check.
const MAX_INLINE_WIDTH: usize = 100;
/// An implementation of the [`UserPreferences`] trait using a TOML file for
/// persistence.
///
/// Settings are organized into hierarchical sections based on the `hierarchy`
/// metadata from the `Setting` trait. Keys are automatically converted from
/// CamelCase to snake_case for idiomatic TOML.
///
/// Values are stored as native TOML types (booleans, integers, floats, strings)
/// rather than JSON-encoded strings, making the file human-readable and
/// hand-editable.
pub struct TomlBackedUserPreferences {
/// The path to the TOML file that backs this preferences store.
file_path: PathBuf,
/// The in-memory TOML document, preserving formatting and comments.
document: RefCell<DocumentMut>,
/// When `true`, writes are silently skipped to avoid overwriting a
/// broken settings file with defaults. Set when the initial parse
/// fails; cleared when [`reload_from_disk`](Self::reload_from_disk)
/// succeeds.
write_inhibited: Cell<bool>,
/// Storage keys whose writes are individually inhibited because the
/// value in the TOML file could not be deserialized into the expected
/// type. Writes and removes for these keys are silently skipped to
/// preserve the user's broken-but-fixable value.
///
/// Cleared on successful [`reload_from_disk`](Self::reload_from_disk)
/// and re-derived by the settings reload logic.
write_inhibited_keys: RefCell<HashSet<String>>,
}
impl TomlBackedUserPreferences {
/// Constructs a new TOML-backed user preferences store.
///
/// If no file exists at the given path, an empty document will be used,
/// and any modifications will trigger creation of the file (including
/// any missing parent directories).
///
/// If the file exists but contains invalid TOML, the store is created
/// with an empty document (so all settings fall back to defaults) and
/// the parse error is returned in the second tuple element. This
/// ensures the caller always gets a functional preferences backend
/// that can recover via [`reload_from_disk`](Self::reload_from_disk)
/// when the user fixes the file.
pub fn new(file_path: PathBuf) -> (Self, Option<Error>) {
let (document, write_inhibited, error) = match Self::load_document(file_path.as_path()) {
Ok(doc) => (doc, false, None),
Err(err) => {
log::warn!(
"Failed to parse settings file at {}: {err}; starting with empty defaults",
file_path.display(),
);
(DocumentMut::new(), true, Some(err))
}
};
(
Self {
file_path,
document: RefCell::new(document),
write_inhibited: Cell::new(write_inhibited),
write_inhibited_keys: RefCell::new(HashSet::new()),
},
error,
)
}
/// Loads the TOML document from disk, or returns an empty document if
/// the file doesn't exist.
fn load_document(file_path: &Path) -> Result<DocumentMut, Error> {
let file_contents = match std::fs::read_to_string(file_path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(DocumentMut::new());
}
Err(err) => return Err(err.into()),
};
if file_contents.trim().is_empty() {
return Ok(DocumentMut::new());
}
file_contents
.parse::<DocumentMut>()
.map_err(|err| Error::Unknown(anyhow::anyhow!(err)))
}
/// Hashes the settings file content on disk.
///
/// Returns `None` if the file is missing, empty/whitespace-only, or
/// unreadable. These cases are all treated as "no local state" rather
/// than "local state that should win" — the caller's startup
/// comparison logic treats a `None` result as "no differing local
/// state" so that cloud can restore rather than wiping cloud with
/// local defaults.
///
/// Uses SHA-256 so that persisted hashes are stable across Rust
/// toolchain upgrades and crate version bumps (unlike `SipHasher`
/// or `DefaultHasher`, whose output is not guaranteed to be stable).
pub fn file_content_hash(file_path: &Path) -> Option<String> {
let contents = match std::fs::read_to_string(file_path) {
Ok(c) => c,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
Err(err) => {
log::warn!(
"Failed to read settings file at {}: {err}",
file_path.display()
);
return None;
}
};
// An empty/whitespace-only file is semantically equivalent to a
// missing file — no settings are defined. Treating them the
// same way avoids wiping cloud with defaults if the user
// empties the file to reset.
if contents.trim().is_empty() {
return None;
}
let digest = Sha256::digest(contents.as_bytes());
Some(format!("{digest:x}"))
}
/// Reloads the TOML document from disk, replacing the in-memory contents.
///
/// On parse failure (e.g. the user introduced a syntax error), the old
/// document is kept and an error is returned.
pub fn reload_from_disk(&self) -> Result<(), Error> {
match Self::load_document(self.file_path.as_path()) {
Ok(doc) => {
*self.document.borrow_mut() = doc;
// The file is now valid — allow writes again.
self.write_inhibited.set(false);
// Clear per-key inhibitions; they will be re-derived by
// the settings reload logic for keys that still fail.
self.write_inhibited_keys.borrow_mut().clear();
Ok(())
}
Err(err) => {
log::warn!(
"Failed to reload settings file at {}: {err}; keeping previous state",
self.file_path.display(),
);
Err(err)
}
}
}
/// Builds a compound key from a storage key and optional hierarchy.
///
/// For example, `("FontSize", Some("font"))` → `"font.FontSize"`.
fn compound_key(key: &str, hierarchy: Option<&str>) -> String {
match hierarchy {
Some(h) => format!("{h}.{key}"),
None => key.to_owned(),
}
}
/// Returns `true` if writes for the given key are individually inhibited.
fn is_key_write_inhibited(&self, key: &str, hierarchy: Option<&str>) -> bool {
let compound = Self::compound_key(key, hierarchy);
self.write_inhibited_keys.borrow().contains(&compound)
}
/// Flushes the in-memory TOML document to disk.
///
/// When writes are inhibited (because the initial parse failed), this
/// is a silent no-op to avoid overwriting the user's broken-but-fixable
/// file with empty defaults.
fn flush(&self) -> Result<(), Error> {
if self.write_inhibited.get() {
return Ok(());
}
let parent_dir = self
.file_path
.parent()
.expect("absolute path to file should have parent");
std::fs::create_dir_all(parent_dir)?;
let data = self.document.borrow().to_string();
std::fs::write(&self.file_path, data)?;
Ok(())
}
/// Navigates to or creates the table for the given hierarchy path.
///
/// For example, `"font.display"` will ensure that `[font.display]` exists
/// and return a mutable reference to that table.
fn get_or_create_table<'a>(table: &'a mut Table, hierarchy: &str) -> &'a mut Table {
let mut current = table;
for segment in hierarchy.split('.') {
if !current.contains_key(segment) || !current[segment].is_table() {
current[segment] = Item::Table(Table::new());
}
current = current[segment]
.as_table_mut()
.expect("just ensured this is a table");
}
current
}
/// Navigates to the table for the given hierarchy path, returning `None`
/// if any segment along the path doesn't exist or isn't a table.
fn get_table<'a>(table: &'a Table, hierarchy: &str) -> Option<&'a Table> {
let mut current = table;
for segment in hierarchy.split('.') {
current = current.get(segment)?.as_table()?;
}
Some(current)
}
/// Converts a JSON-serialized value string into a native TOML [`Item`].
///
/// Primitives become native TOML types, JSON objects become TOML tables,
/// JSON arrays become TOML arrays (with inline tables for object elements),
/// and JSON null is omitted (`Item::None`).
///
/// `remaining_depth` controls how deeply nested objects are rendered as
/// section tables before switching to inline tables:
/// - `None` — unlimited depth (all section tables)
/// - `Some(0)` — fully inline (`{ key = value }`)
/// - `Some(n)` — `n` levels of section tables, then inline
fn json_value_to_toml_item(json_str: &str, remaining_depth: Option<u32>) -> Item {
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(json_str) {
if remaining_depth == Some(0) {
match Self::json_to_toml_value(&json_value) {
Some(v) => Item::Value(v),
None => Item::None,
}
} else {
Self::json_to_toml_item(&json_value, remaining_depth)
}
} else {
// If it's not valid JSON, store as a plain string.
value(json_str)
}
}
/// Recursively converts a parsed JSON value into a TOML [`Item`].
///
/// JSON objects become `Item::Table` (rendered as `[section]` headers),
/// which is more readable than inline tables for struct-valued settings.
///
/// `remaining_depth` controls how many more levels of section tables to
/// allow before switching to inline. `None` means unlimited.
fn json_to_toml_item(json: &serde_json::Value, remaining_depth: Option<u32>) -> Item {
match json {
serde_json::Value::Null => Item::None,
serde_json::Value::Bool(b) => value(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
value(i)
} else if let Some(f) = n.as_f64() {
value(f)
} else {
value(n.to_string())
}
}
serde_json::Value::String(s) => value(s.as_str()),
serde_json::Value::Array(arr) => {
let mut toml_arr = toml_edit::Array::new();
for elem in arr {
if let Some(v) = Self::json_to_toml_value(elem) {
toml_arr.push(v);
}
}
value(toml_arr)
}
serde_json::Value::Object(obj) => {
let child_depth = remaining_depth.map(|d| d.saturating_sub(1));
if child_depth == Some(0) {
// Children should be inline — convert each value inline.
let mut table = Table::new();
for (k, v) in obj {
let item = match Self::json_to_toml_value(v) {
Some(v) => Item::Value(v),
None => Item::None,
};
if !matches!(item, Item::None) {
table[k.as_str()] = item;
}
}
Item::Table(table)
} else {
let mut table = Table::new();
for (k, v) in obj {
let item = Self::json_to_toml_item(v, child_depth);
if !matches!(item, Item::None) {
table[k.as_str()] = item;
}
}
Item::Table(table)
}
}
}
}
/// Recursively pretty-prints an [`Item`] tree in place.
///
/// Inline containers (arrays and inline tables) whose single-line
/// rendering exceeds [`MAX_INLINE_WIDTH`], or which contain a child that
/// itself was made multi-line, get broken across lines using [`INDENT`]
/// per nesting level. Other items are left untouched.
///
/// This is only called on freshly-built items produced by
/// [`Self::json_value_to_toml_item`], so there's no risk of trampling
/// over user-authored formatting for other entries in the file.
fn prettify_item(item: &mut Item, indent_level: usize) {
match item {
Item::None => {}
Item::Value(v) => Self::prettify_value(v, indent_level),
Item::Table(t) => Self::prettify_table(t, indent_level),
Item::ArrayOfTables(arr) => {
for table in arr.iter_mut() {
Self::prettify_table(table, indent_level);
}
}
}
}
fn prettify_value(v: &mut Value, indent_level: usize) {
match v {
Value::Array(arr) => Self::prettify_array(arr, indent_level),
Value::InlineTable(t) => Self::prettify_inline_table(t, indent_level),
Value::String(_)
| Value::Integer(_)
| Value::Float(_)
| Value::Boolean(_)
| Value::Datetime(_) => {}
}
}
fn prettify_table(t: &mut Table, indent_level: usize) {
// A section table's header sits at column 0 regardless of how deeply
// nested it is logically, and so do its `key = value` lines. So the
// effective indent for its children is the same as its own.
for (_, item) in t.iter_mut() {
Self::prettify_item(item, indent_level);
}
}
fn prettify_array(arr: &mut Array, indent_level: usize) {
// Recurse into children first so their multi-line decisions are
// final before we look at the parent.
for v in arr.iter_mut() {
Self::prettify_value(v, indent_level + 1);
}
if arr.is_empty() {
return;
}
let needs_multiline = arr.iter().any(Self::value_rendering_is_multiline)
|| Self::longest_line(&arr.to_string()) > MAX_INLINE_WIDTH;
if !needs_multiline {
return;
}
let child_indent = INDENT.repeat(indent_level + 1);
let outer_indent = INDENT.repeat(indent_level);
let child_prefix = format!("\n{child_indent}");
for v in arr.iter_mut() {
v.decor_mut().set_prefix(child_prefix.clone());
v.decor_mut().set_suffix("");
}
arr.set_trailing_comma(true);
arr.set_trailing(format!("\n{outer_indent}"));
}
fn prettify_inline_table(t: &mut InlineTable, indent_level: usize) {
for (_, v) in t.iter_mut() {
Self::prettify_value(v, indent_level + 1);
}
if t.is_empty() {
return;
}
let needs_multiline = t.iter().any(|(_, v)| Self::value_rendering_is_multiline(v))
|| Self::longest_line(&t.to_string()) > MAX_INLINE_WIDTH;
if !needs_multiline {
return;
}
let child_indent = INDENT.repeat(indent_level + 1);
let outer_indent = INDENT.repeat(indent_level);
let child_prefix = format!("\n{child_indent}");
for (mut key, v) in t.iter_mut() {
key.leaf_decor_mut().set_prefix(child_prefix.clone());
key.leaf_decor_mut().set_suffix(" ");
v.decor_mut().set_prefix(" ");
v.decor_mut().set_suffix("");
}
t.set_trailing_comma(true);
t.set_trailing(format!("\n{outer_indent}"));
}
/// Whether a value's current rendering spans multiple lines.
///
/// Used for the propagation rule: if any child container was already
/// expanded, the parent must be expanded too to avoid ugly output like
/// `[{\n a = 1\n}, ...]`.
fn value_rendering_is_multiline(v: &Value) -> bool {
match v {
Value::Array(_) | Value::InlineTable(_) => v.to_string().contains('\n'),
Value::String(_)
| Value::Integer(_)
| Value::Float(_)
| Value::Boolean(_)
| Value::Datetime(_) => false,
}
}
/// Returns the length (in chars) of the longest line in `s`.
fn longest_line(s: &str) -> usize {
s.lines()
.map(|line| line.chars().count())
.max()
.unwrap_or(0)
}
/// Converts a parsed JSON value into a TOML [`Value`](toml_edit::Value).
///
/// Objects become inline tables (`key = { ... }`), keeping setting values
/// on a single line rather than creating separate `[section]` headers.
fn json_to_toml_value(json: &serde_json::Value) -> Option<toml_edit::Value> {
match json {
serde_json::Value::Null => None,
serde_json::Value::Bool(b) => Some((*b).into()),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Some(i.into())
} else if let Some(f) = n.as_f64() {
Some(f.into())
} else {
Some(n.to_string().into())
}
}
serde_json::Value::String(s) => Some(s.as_str().into()),
serde_json::Value::Array(arr) => {
let mut toml_arr = toml_edit::Array::new();
for elem in arr {
if let Some(v) = Self::json_to_toml_value(elem) {
toml_arr.push(v);
}
}
Some(toml_edit::Value::Array(toml_arr))
}
serde_json::Value::Object(obj) => {
let mut inline_table = toml_edit::InlineTable::new();
for (k, v) in obj {
if let Some(toml_v) = Self::json_to_toml_value(v) {
inline_table.insert(k, toml_v);
}
}
Some(toml_edit::Value::InlineTable(inline_table))
}
}
}
/// Converts a TOML [`Item`] back into a JSON-serialized string that
/// `serde_json::from_str` can parse.
fn toml_item_to_json_string(item: &Item) -> Option<String> {
match item {
Item::None => None,
Item::Value(v) => Self::toml_value_to_json(v),
Item::Table(t) => Some(Self::toml_table_to_json(t)),
Item::ArrayOfTables(arr) => {
let parts: Vec<String> = arr.iter().map(Self::toml_table_to_json).collect();
Some(format!("[{}]", parts.join(",")))
}
}
}
/// Converts a TOML [`Value`](toml_edit::Value) into a JSON string.
fn toml_value_to_json(v: &toml_edit::Value) -> Option<String> {
match v {
toml_edit::Value::Boolean(b) => {
Some(serde_json::to_string(b.value()).unwrap_or_default())
}
toml_edit::Value::Integer(i) => {
Some(serde_json::to_string(i.value()).unwrap_or_default())
}
toml_edit::Value::Float(f) => {
Some(serde_json::to_string(f.value()).unwrap_or_default())
}
toml_edit::Value::String(s) => {
// Always JSON-encode the string value.
Some(serde_json::to_string(s.value()).unwrap_or_default())
}
toml_edit::Value::Array(arr) => {
let parts: Vec<String> = arr.iter().filter_map(Self::toml_value_to_json).collect();
Some(format!("[{}]", parts.join(",")))
}
toml_edit::Value::InlineTable(t) => {
let parts: Vec<String> = t
.iter()
.filter_map(|(k, v)| {
Self::toml_value_to_json(v).map(|json_v| {
format!(
"{}:{}",
serde_json::to_string(k).unwrap_or_default(),
json_v
)
})
})
.collect();
Some(format!("{{{}}}", parts.join(",")))
}
_ => None,
}
}
/// Converts a TOML [`Table`] into a JSON object string.
fn toml_table_to_json(table: &Table) -> String {
let parts: Vec<String> = table
.iter()
.filter_map(|(k, item)| {
Self::toml_item_to_json_string(item).map(|json_v| {
format!(
"{}:{}",
serde_json::to_string(k).unwrap_or_default(),
json_v
)
})
})
.collect();
format!("{{{}}}", parts.join(","))
}
}
impl super::UserPreferences for TomlBackedUserPreferences {
fn is_settings_file(&self) -> bool {
true
}
fn reload_from_disk(&self) -> Result<(), super::Error> {
self.reload_from_disk()
}
fn write_value(&self, key: &str, val: String) -> Result<(), Error> {
self.write_value_with_hierarchy(key, val, None, None)
}
fn read_value(&self, key: &str) -> Result<Option<String>, Error> {
self.read_value_with_hierarchy(key, None)
}
fn remove_value(&self, key: &str) -> Result<(), Error> {
self.remove_value_with_hierarchy(key, None)
}
fn inhibit_writes_for_key(&self, key: &str, hierarchy: Option<&str>) {
let compound = Self::compound_key(key, hierarchy);
log::info!("Inhibiting writes for setting key {compound}");
self.write_inhibited_keys.borrow_mut().insert(compound);
}
fn clear_all_write_inhibitions(&self) {
self.write_inhibited_keys.borrow_mut().clear();
}
fn write_value_with_hierarchy(
&self,
key: &str,
val: String,
hierarchy: Option<&str>,
max_table_depth: Option<u32>,
) -> Result<(), Error> {
if self.is_key_write_inhibited(key, hierarchy) {
return Ok(());
}
let mut item = Self::json_value_to_toml_item(&val, max_table_depth);
// Apply pretty-printing before inserting. The assignment always
// lands at the top of a section table (either the root or the
// `[hierarchy]` table), so the value's own indent level is 0 and
// nested containers get +1 per level.
Self::prettify_item(&mut item, 0);
let mut doc = self.document.borrow_mut();
let table = match hierarchy {
Some(h) => Self::get_or_create_table(doc.as_table_mut(), h),
None => doc.as_table_mut(),
};
table[key] = item;
drop(doc);
self.flush()
}
fn read_value_with_hierarchy(
&self,
key: &str,
hierarchy: Option<&str>,
) -> Result<Option<String>, Error> {
let doc = self.document.borrow();
let table = match hierarchy {
Some(h) => match Self::get_table(doc.as_table(), h) {
Some(t) => t,
None => return Ok(None),
},
None => doc.as_table(),
};
match table.get(key) {
Some(item) => Ok(Self::toml_item_to_json_string(item)),
None => Ok(None),
}
}
fn remove_value_with_hierarchy(&self, key: &str, hierarchy: Option<&str>) -> Result<(), Error> {
if self.is_key_write_inhibited(key, hierarchy) {
return Ok(());
}
let mut doc = self.document.borrow_mut();
let table = match hierarchy {
Some(h) => {
// Navigate to the parent table; if it doesn't exist, nothing to remove.
let mut current = doc.as_table_mut();
for segment in h.split('.') {
if !current.contains_key(segment) || !current[segment].is_table() {
return Ok(());
}
current = current[segment].as_table_mut().ok_or_else(|| {
Error::Unknown(anyhow::anyhow!(
"expected table at segment '{segment}' in hierarchy '{h}'"
))
})?;
}
current
}
None => doc.as_table_mut(),
};
table.remove(key);
drop(doc);
self.flush()
}
}
#[cfg(test)]
#[path = "toml_backed_tests.rs"]
mod tests;
@@ -0,0 +1,745 @@
use super::*;
use toml_edit::Item;
#[test]
fn test_json_to_toml_round_trip_bool() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("true", None);
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, Some("true".to_string()));
}
#[test]
fn test_json_to_toml_round_trip_integer() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("42", None);
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, Some("42".to_string()));
}
#[test]
fn test_json_to_toml_round_trip_float() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("3.14", None);
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, Some("3.14".to_string()));
}
#[test]
fn test_json_to_toml_round_trip_string() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("\"hello\"", None);
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, Some("\"hello\"".to_string()));
}
#[test]
fn test_json_to_toml_round_trip_object() {
let input = r#"{"dark":"Phenomenon","light":"Paper"}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
// Objects should become TOML tables, not strings.
assert!(matches!(item, Item::Table(_)));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_null() {
let item = TomlBackedUserPreferences::json_value_to_toml_item("null", None);
assert!(matches!(item, Item::None));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item);
assert_eq!(json, None);
}
#[test]
fn test_json_to_toml_round_trip_array_of_strings() {
let input = r#"["cat","echo","ls"]"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Value(toml_edit::Value::Array(_))));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_array_of_objects() {
let input = r#"[{"AnchoredRegex":"^bash$"},{"AnchoredRegex":"^fish$"}]"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Value(toml_edit::Value::Array(_))));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_nested_struct() {
let input = r#"{"advanced_mode":false,"global":{"mode":"PreviousDir","custom_dir":""},"split_pane":{"mode":"HomeDir","custom_dir":"/tmp"}}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Table(_)));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_object_with_nulls() {
// Null fields should be omitted from the table, so the round-trip
// drops them. Verify that non-null fields survive.
let input = r#"{"keybinding":null,"active_pin_position":"Top","pin_screen":null,"hide_window_when_unfocused":true}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Table(_)));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
// Null fields are dropped.
let expected = serde_json::json!({
"active_pin_position": "Top",
"hide_window_when_unfocused": true,
});
assert_eq!(actual, expected);
}
#[test]
fn test_json_to_toml_round_trip_empty_array() {
let input = "[]";
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
assert!(matches!(item, Item::Value(toml_edit::Value::Array(_))));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
assert_eq!(json, "[]");
}
#[test]
fn test_max_table_depth_zero_renders_inline() {
// depth 0 = entire value rendered inline
let input = r#"{"uniform_padding":0.0}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, Some(0));
// Should be an inline Value, not an Item::Table
assert!(matches!(
item,
Item::Value(toml_edit::Value::InlineTable(_))
));
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_max_table_depth_one_renders_top_level_as_table_nested_as_inline() {
// depth 1 = top-level object is a section table, but nested objects are inline
let input = r#"{"active_pin_position":"Top","sizes":{"width":100,"height":50},"enabled":true}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, Some(1));
// Top level should be a table
let table = match &item {
Item::Table(t) => t,
other => panic!("expected Table, got {other:?}"),
};
// Primitive fields should be normal values
assert!(matches!(
table.get("active_pin_position"),
Some(Item::Value(_))
));
assert!(matches!(table.get("enabled"), Some(Item::Value(_))));
// Nested object should be an inline table value, not a sub-table
let sizes = table.get("sizes").expect("sizes should exist");
assert!(
matches!(sizes, Item::Value(toml_edit::Value::InlineTable(_))),
"nested object at depth 1 should be inline, got {sizes:?}"
);
// Round-trip the JSON
let json = TomlBackedUserPreferences::toml_item_to_json_string(&item).unwrap();
let expected: serde_json::Value = serde_json::from_str(input).unwrap();
let actual: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn test_max_table_depth_none_renders_all_as_tables() {
// depth None = unlimited, nested objects become section tables
let input = r#"{"sizes":{"width":100,"height":50}}"#;
let item = TomlBackedUserPreferences::json_value_to_toml_item(input, None);
let table = match &item {
Item::Table(t) => t,
other => panic!("expected Table, got {other:?}"),
};
// Nested object should be a sub-table, not inline
assert!(
matches!(table.get("sizes"), Some(Item::Table(_))),
"nested object with None depth should be a section table"
);
}
#[test]
fn test_write_and_read_with_hierarchy() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_settings.toml");
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
prefs
.write_value_with_hierarchy("font_size", "13.0".to_string(), Some("font"), None)
.unwrap();
prefs
.write_value_with_hierarchy(
"use_thin_strokes",
"true".to_string(),
Some("font.display"),
None,
)
.unwrap();
// Read back
let font_name = prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap();
assert_eq!(font_name, Some("\"Hack\"".to_string()));
let font_size = prefs
.read_value_with_hierarchy("font_size", Some("font"))
.unwrap();
assert_eq!(font_size, Some("13.0".to_string()));
let thin_strokes = prefs
.read_value_with_hierarchy("use_thin_strokes", Some("font.display"))
.unwrap();
assert_eq!(thin_strokes, Some("true".to_string()));
// Verify the TOML file structure
let contents = std::fs::read_to_string(&file_path).unwrap();
assert!(contents.contains("[font]"));
assert!(contents.contains("font_name"));
assert!(contents.contains("font_size"));
assert!(contents.contains("[font.display]"));
assert!(contents.contains("use_thin_strokes"));
}
#[test]
fn test_write_and_read_struct_value() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_settings.toml");
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
// Write a struct value (JSON object) under a hierarchy.
let struct_json = r#"{"left_alt":false,"right_alt":true}"#;
prefs
.write_value_with_hierarchy(
"extra_meta_keys",
struct_json.to_string(),
Some("keys"),
None,
)
.unwrap();
// Read back — should produce equivalent JSON.
let read_back = prefs
.read_value_with_hierarchy("extra_meta_keys", Some("keys"))
.unwrap()
.unwrap();
let expected: serde_json::Value = serde_json::from_str(struct_json).unwrap();
let actual: serde_json::Value = serde_json::from_str(&read_back).unwrap();
assert_eq!(actual, expected);
// Verify the TOML file uses a sub-table, not a JSON string.
let contents = std::fs::read_to_string(&file_path).unwrap();
assert!(contents.contains("[keys.extra_meta_keys]"));
assert!(contents.contains("left_alt = false"));
assert!(contents.contains("right_alt = true"));
// Should NOT contain a JSON blob.
assert!(!contents.contains(r#"{"left_alt"#));
}
#[test]
fn test_new_with_invalid_toml_returns_error_and_recovers_on_reload() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("broken_settings.toml");
// Write invalid TOML to the file.
std::fs::write(&file_path, "this is [not valid toml =").unwrap();
// new() should succeed with an empty document and return the parse error.
let (prefs, parse_error) = TomlBackedUserPreferences::new(file_path.clone());
assert!(
parse_error.is_some(),
"expected a parse error for invalid TOML"
);
// The preferences should behave as empty — no values present.
assert_eq!(
prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap(),
None
);
// is_settings_file should still return true.
assert!(prefs.is_settings_file());
// Now fix the file with valid TOML.
std::fs::write(&file_path, "[font]\nfont_name = \"Hack\"\n").unwrap();
// reload_from_disk should succeed and pick up the new value.
assert!(prefs.reload_from_disk().is_ok());
assert_eq!(
prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap(),
Some("\"Hack\"".to_string())
);
}
#[test]
fn test_writes_inhibited_when_file_initially_broken() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("broken_settings.toml");
let original_content = "this is [not valid toml =";
std::fs::write(&file_path, original_content).unwrap();
let (prefs, parse_error) = TomlBackedUserPreferences::new(file_path.clone());
assert!(parse_error.is_some());
// Writing a setting should succeed in-memory but NOT overwrite the file.
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(
on_disk, original_content,
"broken file should not be overwritten by writes during inhibited state"
);
// Fix the file and reload.
std::fs::write(&file_path, "# now valid\n").unwrap();
assert!(prefs.reload_from_disk().is_ok());
// After reload, writes should flush to disk again.
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert!(
on_disk.contains("font_name"),
"writes should flush to disk after successful reload"
);
}
#[test]
fn test_string_value_for_numeric_setting_reads_as_json_string() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_settings.toml");
// Write a TOML file where font_size is a string instead of a number.
std::fs::write(&file_path, "[font]\nfont_size = \"not_a_number\"\n").unwrap();
let (prefs, parse_error) = TomlBackedUserPreferences::new(file_path);
assert!(
parse_error.is_none(),
"valid TOML should parse without error"
);
// Read the value — should return the JSON-encoded string.
let value = prefs
.read_value_with_hierarchy("font_size", Some("font"))
.unwrap();
// The TOML string "not_a_number" should be JSON-encoded as "\"not_a_number\""
assert_eq!(
value,
Some("\"not_a_number\"".to_string()),
"TOML string should be JSON-encoded"
);
// Attempting to deserialize as f32 should fail.
let result = serde_json::from_str::<f32>(value.as_deref().unwrap());
assert!(result.is_err(), "JSON string should not deserialize as f32");
}
#[test]
fn test_remove_with_hierarchy() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_settings.toml");
let (prefs, _) = TomlBackedUserPreferences::new(file_path);
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
let val = prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap();
assert!(val.is_some());
prefs
.remove_value_with_hierarchy("font_name", Some("font"))
.unwrap();
let val = prefs
.read_value_with_hierarchy("font_name", Some("font"))
.unwrap();
assert!(val.is_none());
}
#[test]
fn test_per_key_write_inhibition_preserves_value_in_file() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
// Write a file with one valid and one "invalid" setting (the TOML itself
// is fine, but the caller decides the value is wrong for the type).
std::fs::write(
&file_path,
"[font]\nfont_size = \"abc\"\nfont_name = \"Hack\"\n",
)
.unwrap();
let (prefs, parse_error) = TomlBackedUserPreferences::new(file_path.clone());
assert!(parse_error.is_none(), "TOML itself is valid");
// Simulate the settings layer detecting the bad value.
prefs.inhibit_writes_for_key("font_size", Some("font"));
// Writing to the inhibited key should be a no-op.
prefs
.write_value_with_hierarchy("font_size", "13.0".to_string(), Some("font"), None)
.unwrap();
// Writing to the NON-inhibited key should succeed.
prefs
.write_value_with_hierarchy("font_name", "\"Fira Code\"".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
// The inhibited key's original value must still be present.
assert!(
on_disk.contains("font_size = \"abc\""),
"inhibited key's original value should be preserved, got: {on_disk}"
);
// The non-inhibited key's new value should be written.
assert!(
on_disk.contains("Fira Code"),
"non-inhibited key should be updated, got: {on_disk}"
);
}
#[test]
fn test_per_key_write_inhibition_blocks_remove() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
std::fs::write(&file_path, "[font]\nfont_size = \"abc\"\n").unwrap();
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs.inhibit_writes_for_key("font_size", Some("font"));
// Removing the inhibited key should be a no-op.
prefs
.remove_value_with_hierarchy("font_size", Some("font"))
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert!(
on_disk.contains("font_size = \"abc\""),
"inhibited key should not be removed, got: {on_disk}"
);
}
#[test]
fn test_reload_clears_per_key_inhibitions() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
std::fs::write(&file_path, "[font]\nfont_size = \"abc\"\n").unwrap();
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs.inhibit_writes_for_key("font_size", Some("font"));
// Fix the file.
std::fs::write(&file_path, "[font]\nfont_size = 14.0\n").unwrap();
prefs.reload_from_disk().unwrap();
// After reload, the inhibition is cleared — writes should work.
prefs
.write_value_with_hierarchy("font_size", "16.0".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert!(
on_disk.contains("16.0") || on_disk.contains("16"),
"write should succeed after reload cleared inhibition, got: {on_disk}"
);
}
#[test]
fn test_clear_all_write_inhibitions() {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
std::fs::write(&file_path, "[font]\nfont_size = \"abc\"\nfont_name = 123\n").unwrap();
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs.inhibit_writes_for_key("font_size", Some("font"));
prefs.inhibit_writes_for_key("font_name", Some("font"));
prefs.clear_all_write_inhibitions();
// Both keys should now be writable.
prefs
.write_value_with_hierarchy("font_size", "14.0".to_string(), Some("font"), None)
.unwrap();
prefs
.write_value_with_hierarchy("font_name", "\"Hack\"".to_string(), Some("font"), None)
.unwrap();
let on_disk = std::fs::read_to_string(&file_path).unwrap();
assert!(
on_disk.contains("14") && on_disk.contains("Hack"),
"both keys should be writable after clearing all inhibitions, got: {on_disk}"
);
}
#[test]
fn test_file_content_hash_returns_none_for_missing_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("does_not_exist.toml");
assert_eq!(
None,
TomlBackedUserPreferences::file_content_hash(&file_path)
);
}
#[test]
fn test_file_content_hash_returns_none_for_empty_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("empty.toml");
std::fs::write(&file_path, "").unwrap();
assert_eq!(
None,
TomlBackedUserPreferences::file_content_hash(&file_path)
);
}
#[test]
fn test_file_content_hash_returns_none_for_whitespace_only_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("whitespace.toml");
std::fs::write(&file_path, " \n\t \n").unwrap();
assert_eq!(
None,
TomlBackedUserPreferences::file_content_hash(&file_path)
);
}
#[test]
fn test_file_content_hash_is_deterministic_for_identical_content() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("a.toml");
let path_b = dir.path().join("b.toml");
let contents = "[font]\nfont_size = 14.0\n";
std::fs::write(&path_a, contents).unwrap();
std::fs::write(&path_b, contents).unwrap();
let hash_a = TomlBackedUserPreferences::file_content_hash(&path_a);
let hash_b = TomlBackedUserPreferences::file_content_hash(&path_b);
assert!(hash_a.is_some());
assert_eq!(hash_a, hash_b);
}
#[test]
fn test_file_content_hash_differs_for_different_content() {
let dir = tempfile::tempdir().unwrap();
let path_a = dir.path().join("a.toml");
let path_b = dir.path().join("b.toml");
std::fs::write(&path_a, "[font]\nfont_size = 14.0\n").unwrap();
std::fs::write(&path_b, "[font]\nfont_size = 18.0\n").unwrap();
let hash_a = TomlBackedUserPreferences::file_content_hash(&path_a);
let hash_b = TomlBackedUserPreferences::file_content_hash(&path_b);
assert!(hash_a.is_some());
assert!(hash_b.is_some());
assert_ne!(hash_a, hash_b);
}
// Pretty-printing tests: verify that wide inline containers get broken
// across lines and that short ones stay on a single line. These drive the
// `prettify_item` pass in `toml_backed.rs`.
/// Writes `value_json` under the given hierarchy + key and returns the full
/// file contents. Also asserts the round-trip (read back as JSON matches).
fn write_and_read_file(
hierarchy: Option<&str>,
key: &str,
value_json: &str,
max_table_depth: Option<u32>,
) -> String {
use super::super::UserPreferences;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("settings.toml");
let (prefs, _) = TomlBackedUserPreferences::new(file_path.clone());
prefs
.write_value_with_hierarchy(key, value_json.to_string(), hierarchy, max_table_depth)
.unwrap();
// Round-trip: reading back should yield the same JSON value.
let read_back = prefs
.read_value_with_hierarchy(key, hierarchy)
.unwrap()
.expect("value was just written");
let expected: serde_json::Value = serde_json::from_str(value_json).unwrap();
let actual: serde_json::Value = serde_json::from_str(&read_back).unwrap();
assert_eq!(actual, expected, "round-trip should preserve the value");
std::fs::read_to_string(&file_path).unwrap()
}
#[test]
fn test_pretty_print_wide_array_of_inline_tables() {
// Shaped like `custom_secret_regex_list`: each inline table has a
// short field count but the whole array is very wide.
let json = r#"[
{"name":"IPv4 Address","pattern":"\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b"},
{"name":"OpenAI API Key","pattern":"\\bsk-[a-zA-Z0-9]{48}\\b"},
{"name":"GitHub Token","pattern":"\\bghp_[A-Za-z0-9_]{36}\\b"}
]"#;
let contents = write_and_read_file(Some("privacy"), "custom_secret_regex_list", json, None);
// Expect each inline table on its own indented line, with the closing
// bracket on a fresh line.
assert!(
contents.contains("custom_secret_regex_list = [\n"),
"array should open with a newline, got:\n{contents}"
);
assert!(
contents.contains("\n { name = \"IPv4 Address\""),
"each inline table should start on a new indented line, got:\n{contents}"
);
assert!(
contents.contains("\n]\n") || contents.ends_with("\n]"),
"closing bracket should be on its own line, got:\n{contents}"
);
// Each inline table itself is short (2 fields, fits well under
// MAX_INLINE_WIDTH), so it should still be on a single line.
assert!(
!contents.contains("{ name =\n") && !contents.contains("{\n name"),
"individual inline tables should stay on one line, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_short_primitive_array_stays_inline() {
let json = r#"["cat","echo","ls"]"#;
let contents = write_and_read_file(Some("terminal"), "allowed_commands", json, None);
// The whole thing fits on one line; it should stay inline.
assert!(
contents.contains("allowed_commands = [\"cat\", \"echo\", \"ls\"]"),
"short primitive array should stay inline, got:\n{contents}"
);
assert!(
!contents.contains("allowed_commands = [\n"),
"short primitive array should not be broken, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_long_primitive_array_goes_multiline() {
// Construct a primitive array whose single-line rendering clearly
// exceeds `MAX_INLINE_WIDTH`.
let items: Vec<String> = (0..20)
.map(|i| format!("\"/some/fairly/long/path/entry/number/{i}\""))
.collect();
let json = format!("[{}]", items.join(","));
let contents = write_and_read_file(Some("agents"), "allowlist", &json, None);
assert!(
contents.contains("allowlist = [\n"),
"long primitive array should open multi-line, got:\n{contents}"
);
// Each element should sit on its own indented line.
assert!(
contents.contains("\n \"/some/fairly/long/path/entry/number/0\","),
"first element should be on its own indented line with trailing comma, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_short_inline_table_stays_inline() {
// Force inline via max_table_depth = 0 and verify it stays on one line.
let json = r#"{"a":1,"b":2}"#;
let contents = write_and_read_file(Some("section"), "small", json, Some(0));
assert!(
contents.contains("small = { a = 1, b = 2 }"),
"short inline table should stay on one line, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_wide_inline_table_goes_multiline() {
// Force inline via max_table_depth = 0, with many long-valued fields so
// the single-line rendering is wider than `MAX_INLINE_WIDTH`.
let json = concat!(
"{",
"\"first\":\"a string value that is long enough\",",
"\"second\":\"another string value that is long enough\",",
"\"third\":\"one more string value that is long enough\"",
"}"
);
let contents = write_and_read_file(Some("section"), "big", json, Some(0));
assert!(
contents.contains("big = {\n"),
"wide inline table should open multi-line, got:\n{contents}"
);
// Each field should sit on its own indented line.
assert!(
contents.contains("\n first = "),
"first field should be on its own indented line, got:\n{contents}"
);
assert!(
contents.contains("\n second = "),
"second field should be on its own indented line, got:\n{contents}"
);
}
#[test]
fn test_pretty_print_propagates_to_parent_array() {
// A short outer array (one element) containing a wide inline table.
// The inline table must go multi-line (width rule), and that forces
// the array multi-line too (propagation rule) — otherwise we'd render
// something ugly like `[{\n ...\n}]`.
let json = concat!(
"[{",
"\"first\":\"a string value that is long enough\",",
"\"second\":\"another string value that is long enough\",",
"\"third\":\"one more string value that is long enough\"",
"}]"
);
let contents = write_and_read_file(Some("section"), "items", json, None);
assert!(
contents.contains("items = [\n"),
"array should be multi-line because its child is multi-line, got:\n{contents}"
);
assert!(
contents.contains("{\n"),
"inline table should be multi-line, got:\n{contents}"
);
}
@@ -0,0 +1,100 @@
//! Implementation of the [`UserPreferences`] trait using macOS user defaults.
#![allow(deprecated)]
use cocoa::base::{id, nil};
use objc::{class, msg_send, rc::StrongPtr, sel, sel_impl};
/// A user preferences store backed by macOS user defaults (`NSUserDefaults`).
pub struct UserDefaultsPreferencesStorage {
/// A strong reference to the `NSUserDefaults` backing store.
user_defaults: StrongPtr,
}
impl UserDefaultsPreferencesStorage {
/// Constructs a new preferences store.
///
/// If `suite_name` is provided, it is used as the domain within
/// the user defaults system. Otherwise, the standard user defaults for
/// the current application are used.
pub fn new(suite_name: Option<String>) -> Self {
Self {
user_defaults: Self::user_defaults(suite_name),
}
}
/// Returns a strong reference to the `NSUserDefaults` backing store that
/// should be used for the given suite name.
///
/// If [`None`] is provided as the suite name, the standard user defaults
/// will be used (namespaced based on the current application).
fn user_defaults(suite_name: Option<String>) -> StrongPtr {
unsafe {
// Calling `[[NSUserDefaults alloc] initWithSuiteName]`` where the suite name is the
// application's bundle ID (the default `data_domain` if `data_profile` is unset)
// _should_ be equivalent to `[NSUserDefaults standardUserDefaults]`. However, in case
// the two ever deviate, we explicitly use `standardUserDefaults` below. The Apple docs
// also imply that `standardUserDefaults` is cached.
if let Some(suite_name) = &suite_name {
let defaults: id = msg_send![class!(NSUserDefaults), alloc];
let suite_name = util::make_nsstring(suite_name);
StrongPtr::new(msg_send![defaults, initWithSuiteName: *suite_name])
} else {
StrongPtr::retain(msg_send![class!(NSUserDefaults), standardUserDefaults])
}
}
}
}
impl super::UserPreferences for UserDefaultsPreferencesStorage {
fn write_value(&self, key: &str, value: String) -> Result<(), super::Error> {
unsafe {
let key = util::make_nsstring(key);
let value = util::make_nsstring(&value);
let _: () = msg_send![*self.user_defaults, setObject: *value forKey: *key];
Ok(())
}
}
fn read_value(&self, key: &str) -> Result<Option<String>, super::Error> {
unsafe {
let key = util::make_nsstring(key);
let value: id = msg_send![*self.user_defaults, stringForKey: *key];
if value != nil {
Ok(Some(
galaxyui::platform::mac::utils::nsstring_as_str(value)?.to_owned(),
))
} else {
Ok(None)
}
}
}
fn remove_value(&self, key: &str) -> Result<(), super::Error> {
unsafe {
let key = util::make_nsstring(key);
let _: () = msg_send![*self.user_defaults, removeObjectForKey: *key];
Ok(())
}
}
}
mod util {
use cocoa::{base::nil, foundation::NSString};
use objc::rc::StrongPtr;
/// Creates a new `NSString` from the given `&str`, wrapped in a
/// [`StrongPtr`] so it is released when the `StrongPtr` is dropped.
///
/// **Important:** when passing the result to `msg_send!`, always
/// dereference it (e.g. `msg_send![obj, foo: *nsstring]`). Passing the
/// `StrongPtr` itself by value causes it to be moved into the
/// `unsafe extern fn` call that `msg_send!` transmutes to, and Rust will
/// not run the `StrongPtr`'s `Drop` glue after that call, leaking the
/// underlying `NSString`.
pub fn make_nsstring(value: &str) -> StrongPtr {
unsafe { StrongPtr::new(NSString::alloc(nil).init_str(value)) }
}
}