diff --git a/crates/kv-ffi/src/app_settings.rs b/crates/kv-ffi/src/app_settings.rs new file mode 100644 index 0000000..5579298 --- /dev/null +++ b/crates/kv-ffi/src/app_settings.rs @@ -0,0 +1,86 @@ +//! Settings that follow the user to every device (kestrove-spec/docs/02 "App settings"): the colour theme and +//! light/dark appearance. They live in one end-to-end encrypted item per account (a secure note carrying +//! `app_settings`, at `kv_model::app_settings_id`), so they sync like everything else and the server can't read +//! them. Apps hide that item; older versions show it as a note, which explains itself. + +use kv_model::{app_settings_id, AppSettings, Item, ItemKind}; + +use crate::{decrypt, now, CoreError, Result, VaultCore}; + +/// Theme ids the apps offer, in display order (kestrove-spec/design/themes.json). Unknown ids from newer apps are +/// kept and passed through; each app falls back to `spruce` for one it doesn't know. +pub const THEMES: &[&str] = &["spruce", "ocean", "violet", "rose", "crimson", "amber", "graphite"]; +const APPEARANCES: &[&str] = &["system", "light", "dark"]; + +const NOTE_TITLE: &str = "Kestrove settings"; +const NOTE_TEXT: &str = "Kestrove keeps the settings that sync between your devices here, such as your colour theme. \ +Newer versions of the app hide this note. Deleting it only resets those settings."; + +#[derive(Debug, Clone, PartialEq, Eq, Default, uniffi::Record)] +pub struct AppThemeDto { + /// A theme id (see `app_themes`), or none if never set (apps use `spruce`). + pub theme: Option, + /// `system`, `light` or `dark`; none if never set (apps follow the system). + pub appearance: Option, +} + +/// The theme ids the apps offer, in order. +#[uniffi::export] +pub fn app_themes() -> Vec { + THEMES.iter().map(|t| (*t).to_string()).collect() +} + +fn valid_id(s: &str) -> bool { + !s.is_empty() && s.len() <= 32 && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +#[uniffi::export] +impl VaultCore { + /// The theme synced across this account's devices. Empty when none was ever synced. + pub fn app_theme(&self) -> Result { + let (acct, u) = self.unlocked_snapshot()?; + let id = app_settings_id(acct.account_id); + let Some(li) = self.store.get(acct.primary_vault, id)? else { return Ok(AppThemeDto::default()) }; + let s = decrypt(&acct, &u.vault_key, &li.record)?.and_then(|i| i.app_settings.clone()).unwrap_or_default(); + Ok(AppThemeDto { theme: s.theme, appearance: s.appearance }) + } + + /// Saves the theme for every device of this account (it syncs with the next sync). Keeps every other setting. + pub fn set_app_theme(&self, theme: Option, appearance: Option) -> Result<()> { + if theme.as_deref().is_some_and(|t| !valid_id(t)) { + return Err(CoreError::Invalid { msg: "theme".into() }); + } + if appearance.as_deref().is_some_and(|a| !APPEARANCES.contains(&a)) { + return Err(CoreError::Invalid { msg: "appearance".into() }); + } + let (acct, u) = self.unlocked_snapshot()?; + let id = app_settings_id(acct.account_id); + let existing = self.store.get(acct.primary_vault, id)?; + let current = match &existing { + Some(li) => decrypt(&acct, &u.vault_key, &li.record)?, + None => None, + }; + let mut model = current.unwrap_or_else(|| { + let mut item = Item::new_login(NOTE_TITLE, now()); + item.id = id; + item.kind = ItemKind::SecureNote; + item.notes = Some(NOTE_TEXT.into()); + item + }); + let mut settings = model.app_settings.take().unwrap_or_else(AppSettings::default); + if settings.theme == theme && settings.appearance == appearance && existing.is_some() { + model.app_settings = Some(settings); + return Ok(()); // nothing changed: no new version to sync + } + settings.theme = theme; + settings.appearance = appearance; + model.app_settings = Some(settings); + let existing = existing.filter(|li| !li.record.deleted); + self.write_item(&acct, &u, &mut model, existing) + } +} + +/// True for the account's settings item, which the vault list never shows. +pub(crate) fn is_settings_item(item: &Item) -> bool { + item.app_settings.is_some() +} diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 8fb5992..ec7feb6 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -32,7 +32,9 @@ use zeroize::Zeroizing; uniffi::setup_scaffolding!(); +mod app_settings; mod passkeys; +pub use app_settings::{app_themes, AppThemeDto}; pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary}; mod import; pub use import::{DuplicateHandling, ImportFormat, ImportOptions, ImportPreview, ImportReport}; @@ -615,7 +617,9 @@ impl VaultCore { let mut out = Vec::new(); for li in self.store.list(acct.primary_vault)? { if let Some(item) = decrypt(&acct, &u.vault_key, &li.record)? { - out.push(summary(&item)); + if !app_settings::is_settings_item(&item) { + out.push(summary(&item)); + } } } out.sort_by(|a, b| { diff --git a/crates/kv-ffi/src/passkeys.rs b/crates/kv-ffi/src/passkeys.rs index c25bf4c..55c3859 100644 --- a/crates/kv-ffi/src/passkeys.rs +++ b/crates/kv-ffi/src/passkeys.rs @@ -233,7 +233,7 @@ impl VaultCore { } /// Seals and stores a modified item like `save_item` does: next `seq`, previous version to history. - fn write_item( + pub(crate) fn write_item( &self, acct: &AccountRecord, u: &Unlocked, diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs index c325ffe..56de107 100644 --- a/crates/kv-ffi/src/tests.rs +++ b/crates/kv-ffi/src/tests.rs @@ -484,7 +484,9 @@ async fn scan_to_join_between_two_clients() { let st = server.state(); let bundle = st.joins.values().next().unwrap().bundle.as_ref().unwrap(); let raw = serde_json::to_string(bundle).unwrap(); - assert!(!raw.contains("K1") && !raw.contains("kestrove://")); + // Not the Secret Key in either form (random ciphertext can contain "K1" by chance, so check the key). + let compact: String = sk.chars().filter(|c| *c != '-').collect(); + assert!(!raw.contains(&sk) && !raw.contains(&compact[2..]) && !raw.contains("kestrove://")); assert_eq!(st.tickets.values().filter(|t| t.bound_key_sha256.is_some()).count(), 1); } let done = desktop.join_poll().await.unwrap(); @@ -742,3 +744,47 @@ fn sign_in_with_is_saved_listed_and_normalised() { assert_eq!(sign_in_providers()[0].name, "Google"); assert_eq!(sign_in_provider_name("apple".into()), "Apple"); } + +#[tokio::test] +async fn theme_syncs_between_devices_and_stays_out_of_the_list() { + let server = TestServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap(); + let sk = a.create_account(PW.into()).unwrap(); + connect(&a, &server); + a.register_account(SETUP_TOKEN.into(), None).await.unwrap(); + assert_eq!(a.app_theme().unwrap(), AppThemeDto::default()); + + a.set_app_theme(Some("ocean".into()), Some("dark".into())).unwrap(); + assert_eq!(a.app_theme().unwrap(), AppThemeDto { theme: Some("ocean".into()), appearance: Some("dark".into()) }); + assert!(a.list_items().unwrap().is_empty(), "the settings item is hidden"); + assert_eq!(a.sync_now().await.unwrap().pushed, 1); + // Setting the same theme again writes nothing. + a.set_app_theme(Some("ocean".into()), Some("dark".into())).unwrap(); + assert_eq!(a.sync_now().await.unwrap().pushed, 0); + + let b = VaultCore::open(dir.path().join("b.sqlite").to_string_lossy().into_owned()).unwrap(); + connect(&b, &server); + b.login(PW.into(), sk, None, None).await.unwrap(); + b.sync_now().await.unwrap(); + assert_eq!(b.app_theme().unwrap().theme.as_deref(), Some("ocean")); + + // Both change it before syncing: still one item, and both devices end up with the same choice (the whole + // settings object is one merge unit, so the later change wins). + a.set_app_theme(Some("violet".into()), Some("dark".into())).unwrap(); + b.set_app_theme(Some("ocean".into()), Some("light".into())).unwrap(); + a.sync_now().await.unwrap(); + b.sync_now().await.unwrap(); + a.sync_now().await.unwrap(); + let (ta, tb) = (a.app_theme().unwrap(), b.app_theme().unwrap()); + assert_eq!(ta, tb); + assert!( + ta == AppThemeDto { theme: Some("violet".into()), appearance: Some("dark".into()) } + || ta == AppThemeDto { theme: Some("ocean".into()), appearance: Some("light".into()) } + ); + assert!(a.list_items().unwrap().is_empty() && b.list_items().unwrap().is_empty()); + + assert!(matches!(a.set_app_theme(Some("Not A Theme!".into()), None), Err(CoreError::Invalid { .. }))); + assert!(matches!(a.set_app_theme(None, Some("purple".into())), Err(CoreError::Invalid { .. }))); + assert_eq!(app_themes()[0], "spruce"); +} diff --git a/crates/kv-model/src/item.rs b/crates/kv-model/src/item.rs index 36be126..a390c19 100644 --- a/crates/kv-model/src/item.rs +++ b/crates/kv-model/src/item.rs @@ -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, + /// `system`, `light` or `dark`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub appearance: Option, + /// Settings from newer apps, kept as they are. + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// 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, + /// Only on the account's settings item (see [`AppSettings`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_settings: Option, 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()); + } +} diff --git a/crates/kv-model/src/lib.rs b/crates/kv-model/src/lib.rs index 43d888b..6c41672 100644 --- a/crates/kv-model/src/lib.rs +++ b/crates/kv-model/src/lib.rs @@ -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}; diff --git a/crates/kv-wasm/src/core.rs b/crates/kv-wasm/src/core.rs index 440e97e..af29443 100644 --- a/crates/kv-wasm/src/core.rs +++ b/crates/kv-wasm/src/core.rs @@ -257,7 +257,9 @@ impl WebCore { pub fn list_items(&self) -> Result> { let (acct, u) = self.unlocked_snapshot()?; - let mut out: Vec = self.all_items(&acct, &u)?.iter().map(summary).collect(); + // The account's settings item (docs/02 "App settings") is never listed. + let mut out: Vec = + self.all_items(&acct, &u)?.iter().filter(|i| i.app_settings.is_none()).map(summary).collect(); out.sort_by(|a, b| { b.favorite.cmp(&a.favorite).then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())) }); @@ -322,6 +324,57 @@ impl WebCore { Ok(self.store.put_local(vault, &tomb)?) } + /// The theme synced across this account's devices (kv-ffi `app_theme`): `(theme, appearance)`. + pub fn app_theme(&self) -> Result { + let (acct, u) = self.unlocked_snapshot()?; + let id = kv_model::app_settings_id(acct.account_id); + let Some(li) = self.store.get(acct.primary_vault, id)? else { return Ok(AppThemeDto::default()) }; + let s = decrypt(&acct, &u.vault_key, &li.record)?.and_then(|i| i.app_settings.clone()).unwrap_or_default(); + Ok(AppThemeDto { theme: s.theme, appearance: s.appearance }) + } + + /// Saves the theme for every device of this account (kv-ffi `set_app_theme`; same item, same rules). + pub fn set_app_theme(&self, theme: Option, appearance: Option) -> Result<()> { + let valid = |s: &str| { + !s.is_empty() + && s.len() <= 32 + && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }; + if theme.as_deref().is_some_and(|t| !valid(t)) { + return Err(CoreError::Invalid { msg: "theme".into() }); + } + if appearance.as_deref().is_some_and(|a| !["system", "light", "dark"].contains(&a)) { + return Err(CoreError::Invalid { msg: "appearance".into() }); + } + let (acct, u) = self.unlocked_snapshot()?; + let id = kv_model::app_settings_id(acct.account_id); + let existing = self.store.get(acct.primary_vault, id)?; + let current = match &existing { + Some(li) => decrypt(&acct, &u.vault_key, &li.record)?, + None => None, + }; + let had = current.is_some(); + let mut model = current.unwrap_or_else(|| { + let mut item = Item::new_login("Kestrove settings", now()); + item.id = id; + item.kind = kv_model::ItemKind::SecureNote; + item.notes = Some( + "Kestrove keeps the settings that sync between your devices here, such as your colour theme. \ + Newer versions of the app hide this note. Deleting it only resets those settings." + .into(), + ); + item + }); + let mut settings = model.app_settings.take().unwrap_or_default(); + if had && settings.theme == theme && settings.appearance == appearance { + return Ok(()); + } + settings.theme = theme; + settings.appearance = appearance; + model.app_settings = Some(settings); + self.write_item(&acct, &u, &mut model, existing.filter(|_| had), had) + } + /// Earlier versions of an item (local edits and merge losers), newest first. At most 10. pub fn item_history(&self, id: &str) -> Result> { let id = parse_id(id)?; diff --git a/crates/kv-wasm/src/dto.rs b/crates/kv-wasm/src/dto.rs index 0125c51..dd7633a 100644 --- a/crates/kv-wasm/src/dto.rs +++ b/crates/kv-wasm/src/dto.rs @@ -313,3 +313,10 @@ pub struct JoinConfirm { /// Same format as `DeviceDto.key_fingerprint`. pub key_fingerprint: String, } + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppThemeDto { + pub theme: Option, + pub appearance: Option, +} diff --git a/crates/kv-wasm/src/wasm/mod.rs b/crates/kv-wasm/src/wasm/mod.rs index a5247d4..aba5ab3 100644 --- a/crates/kv-wasm/src/wasm/mod.rs +++ b/crates/kv-wasm/src/wasm/mod.rs @@ -51,6 +51,9 @@ export interface SignInWith { provider: string; account?: string | null; } export interface SignInProvider { id: string; name: string; } +/** Synced theme (docs/02 "App settings"): a theme id such as "ocean", and "system" | "light" | "dark". */ +export interface AppThemeDto { theme: string | null; appearance: string | null; } + /** `id: ""` creates a new item. Omitted optional fields default to null / [] / false / 0. */ export interface ItemDto { id: string; @@ -525,6 +528,17 @@ impl VaultCore { self.run(|s| async move { s.core.list_items() }) } + /// The colour theme and appearance synced across this account's devices. + #[wasm_bindgen(js_name = appTheme, unchecked_return_type = "Promise")] + pub fn app_theme(&self) -> Promise { + self.run(|s| async move { s.core.app_theme() }) + } + + #[wasm_bindgen(js_name = setAppTheme, unchecked_return_type = "Promise")] + pub fn set_app_theme(&self, theme: Option, appearance: Option) -> Promise { + self.run(move |s| async move { s.core.set_app_theme(theme, appearance) }) + } + #[wasm_bindgen(js_name = getItem, unchecked_return_type = "Promise")] pub fn get_item(&self, id: String) -> Promise { self.run(move |s| async move { s.core.get_item(&id) }) diff --git a/crates/kv-wasm/tests/sync.rs b/crates/kv-wasm/tests/sync.rs index f60f414..ca83b5f 100644 --- a/crates/kv-wasm/tests/sync.rs +++ b/crates/kv-wasm/tests/sync.rs @@ -420,3 +420,26 @@ async fn removed_web_vault_reenrols_with_the_same_ticket() { web.forget_revoked_device().unwrap(); assert!(matches!(app.approve_device(old.to_string()).await, Err(kv_ffi::CoreError::NotFound))); } + +#[tokio::test] +async fn theme_set_in_the_app_reaches_the_web_vault_and_back() { + let server = TestServer::start().await; + let (app, _sk, _) = app_with_item(&server).await; + app.set_app_theme(Some("amber".into()), Some("light".into())).unwrap(); + app.sync_now().await.unwrap(); + + let add = app.add_device_payload().await.unwrap(); + let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap(); + let (_store, web) = new_web(&server); + web.login_with_setup(&setup, PW).await.unwrap(); + web.sync_now().await.unwrap(); + let t = web.app_theme().unwrap(); + assert_eq!((t.theme.as_deref(), t.appearance.as_deref()), (Some("amber"), Some("light"))); + assert_eq!(web.list_items().unwrap().len(), 1, "only Bank; the settings item is hidden"); + + web.set_app_theme(Some("rose".into()), Some("dark".into())).unwrap(); + web.sync_now().await.unwrap(); + app.sync_now().await.unwrap(); + assert_eq!(app.app_theme().unwrap().theme.as_deref(), Some("rose")); + assert_eq!(app.list_items().unwrap().len(), 1); +}