Synced app settings: colour theme and appearance (beta)
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user