Synced app settings: colour theme and appearance (beta)
ci / test (push) Waiting to run
ci / wasm (push) Waiting to run
ci / deny (push) Waiting to run
ci / fuzz (push) Waiting to run

One encrypted settings item per account (a secure note carrying
app_settings, at a fixed per-account id) holds the theme and light/dark
appearance, so it syncs like any item and the server can't read it. Lists
hide it; older apps see a note that explains itself. app_theme /
set_app_theme in kv-ffi and kv-wasm. Also fixes a flaky test that checked
random ciphertext for the letters K1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-27 00:12:08 +07:00
co-authored by Claude Opus 5.5
parent 203bd9044d
commit 7c8a5b668f
10 changed files with 298 additions and 5 deletions
+57
View File
@@ -58,6 +58,32 @@ pub fn provider_name(id: &str) -> &str {
PROVIDERS.iter().find(|(p, _, _)| *p == id).map_or(id, |(_, name, _)| name)
}
/// Settings that follow the user across devices (docs/02 "App settings"), kept in one item per account: a
/// secure note with this field, at [`app_settings_id`]. Apps hide that item; older versions show it as a note.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppSettings {
/// Colour theme id: `spruce`, `ocean`, `violet`, `rose`, `crimson`, `amber`, `graphite`, or one a newer app knows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
/// `system`, `light` or `dark`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub appearance: Option<String>,
/// Settings from newer apps, kept as they are.
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
/// The id of the account's settings item: the same on every device, so two devices that create it at the same
/// time write the same item (and the sync merge combines them) instead of two copies.
pub fn app_settings_id(account_id: Uuid) -> Uuid {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(b"kestrove/app-settings/v1");
h.update(account_id.as_bytes());
let bytes: [u8; 16] = h.finalize()[..16].try_into().expect("16 bytes");
uuid::Builder::from_random_bytes(bytes).into_uuid()
}
/// One item. Field-level structure is what makes three-way merges possible (docs/03).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Item {
@@ -87,6 +113,9 @@ pub struct Item {
/// The account signs in through an identity provider instead of (or as well as) a password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sign_in_with: Option<SignInWith>,
/// Only on the account's settings item (see [`AppSettings`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_settings: Option<AppSettings>,
pub created_at: i64,
pub modified_at: i64,
/// Per-item edit counter, bound inside the ciphertext: every new version has a larger `seq` than the
@@ -115,6 +144,7 @@ impl Item {
favorite: false,
android_apps: vec![],
sign_in_with: None,
app_settings: None,
created_at: now,
modified_at: now,
seq: 1,
@@ -201,3 +231,30 @@ mod format_tests {
assert_eq!(provider_name("newco"), "newco");
}
}
#[cfg(test)]
mod settings_tests {
use super::*;
#[test]
fn settings_id_is_stable_per_account() {
let a = Uuid::parse_str("6f1c2b9a-4e0d-4c7f-9b3a-8e2d1c0b9a87").unwrap();
let b = Uuid::parse_str("11111111-2222-4333-8444-555555555555").unwrap();
assert_eq!(app_settings_id(a), app_settings_id(a));
assert_ne!(app_settings_id(a), app_settings_id(b));
assert_eq!(app_settings_id(a).get_version_num(), 4);
}
#[test]
fn settings_round_trip_and_keep_unknown_keys() {
let v = serde_json::json!({"theme": "ocean", "appearance": "dark", "font_size": "large"});
let s: AppSettings = serde_json::from_value(v.clone()).unwrap();
assert_eq!((s.theme.as_deref(), s.appearance.as_deref()), (Some("ocean"), Some("dark")));
assert_eq!(serde_json::to_value(&s).unwrap(), v);
// An older item without the field still reads.
let item: Item = serde_json::from_value(serde_json::json!({
"id": "6f1c2b9a-4e0d-4c7f-9b3a-8e2d1c0b9a87", "kind": "secure_note", "title": "x", "created_at": 1, "modified_at": 1
})).unwrap();
assert!(item.app_settings.is_none());
}
}
+4 -1
View File
@@ -7,7 +7,10 @@ pub mod matching;
pub mod passkey;
pub mod totp;
pub use item::{normalize_tags, provider_name, Item, ItemKind, Passkey, SignInWith, MAX_TAGS, MAX_TAG_LEN, PROVIDERS};
pub use item::{
app_settings_id, normalize_tags, provider_name, AppSettings, Item, ItemKind, Passkey, SignInWith, MAX_TAGS,
MAX_TAG_LEN, PROVIDERS,
};
#[cfg(feature = "psl")]
pub use matching::registrable_domain;
pub use matching::{match_url, MatchLevel};