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
+54 -1
View File
@@ -257,7 +257,9 @@ impl WebCore {
pub fn list_items(&self) -> Result<Vec<ItemSummary>> {
let (acct, u) = self.unlocked_snapshot()?;
let mut out: Vec<ItemSummary> = 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<ItemSummary> =
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<AppThemeDto> {
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<String>, appearance: Option<String>) -> 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<Vec<ItemDto>> {
let id = parse_id(id)?;
+7
View File
@@ -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<String>,
pub appearance: Option<String>,
}
+14
View File
@@ -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<AppThemeDto>")]
pub fn app_theme(&self) -> Promise {
self.run(|s| async move { s.core.app_theme() })
}
#[wasm_bindgen(js_name = setAppTheme, unchecked_return_type = "Promise<void>")]
pub fn set_app_theme(&self, theme: Option<String>, appearance: Option<String>) -> Promise {
self.run(move |s| async move { s.core.set_app_theme(theme, appearance) })
}
#[wasm_bindgen(js_name = getItem, unchecked_return_type = "Promise<ItemDto>")]
pub fn get_item(&self, id: String) -> Promise {
self.run(move |s| async move { s.core.get_item(&id) })
+23
View File
@@ -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);
}