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:
@@ -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<String>,
|
||||
/// `system`, `light` or `dark`; none if never set (apps follow the system).
|
||||
pub appearance: Option<String>,
|
||||
}
|
||||
|
||||
/// The theme ids the apps offer, in order.
|
||||
#[uniffi::export]
|
||||
pub fn app_themes() -> Vec<String> {
|
||||
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<AppThemeDto> {
|
||||
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<String>, appearance: Option<String>) -> 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()
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user