//! Window-local mode/schema preferences, with application defaults for reopening. //! Persist preferences only: never composition, surrounding text or window titles. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct Profile { pub schema: String, pub ascii: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Context { pub session: String, pub window: u64, pub app_id: String, } impl Context { fn key(&self) -> String { format!("{}:{}", self.session, self.window) } } #[derive(Default, Deserialize, Serialize)] struct Stored { #[serde(default)] applications: BTreeMap, #[serde(default)] windows: BTreeMap, } pub struct Profiles { path: PathBuf, default: Profile, stored: Stored, dirty: bool, pub context: Option, pub current: Profile, } impl Profiles { pub fn open(path: &Path, schema: String) -> Result { let stored = match std::fs::read(path) { Ok(bytes) => serde_json::from_slice(&bytes) .map_err(|e| format!("invalid IME preferences: {e}"))?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Stored::default(), Err(e) => return Err(e.to_string()), }; let default = Profile { schema, ascii: false, }; Ok(Self { path: path.to_owned(), stored, dirty: false, context: None, current: default.clone(), default, }) } pub fn focus(&mut self, context: Option) -> Profile { if let Some(context) = &context { let prefix = format!("{}:", context.session); self.stored .windows .retain(|key, _| key.starts_with(&prefix)); } let profile = context .as_ref() .and_then(|c| { self.stored .windows .get(&c.key()) .or_else(|| self.stored.applications.get(&c.app_id)) }) .unwrap_or(&self.default) .clone(); self.context = context; self.current = profile.clone(); // Pin even the initial default. A second window of the same application // can change its mode without changing this already-open window. if let Some(context) = &self.context { self.dirty |= !self.stored.windows.contains_key(&context.key()); self.stored .windows .entry(context.key()) .or_insert_with(|| profile.clone()); } profile } pub fn remember(&mut self, profile: Profile) -> Result<(), String> { if self.current == profile { return self.flush(); } self.current = profile.clone(); let Some(context) = &self.context else { return Ok(()); }; self.stored.windows.insert(context.key(), profile.clone()); if !context.app_id.is_empty() { self.stored .applications .insert(context.app_id.clone(), profile); } self.dirty = true; self.flush() } pub fn flush(&mut self) -> Result<(), String> { if self.dirty { self.save()?; self.dirty = false; } Ok(()) } fn save(&self) -> Result<(), String> { let parent = self.path.parent().ok_or("preferences path has no parent")?; std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; temp.as_file() .set_permissions(std::fs::Permissions::from_mode(0o600)) .map_err(|e| e.to_string())?; serde_json::to_writer_pretty(&mut temp, &self.stored).map_err(|e| e.to_string())?; temp.write_all(b"\n") .and_then(|()| temp.as_file().sync_all()) .map_err(|e| e.to_string())?; temp.persist(&self.path).map_err(|e| e.to_string())?; Ok(()) } } #[cfg(test)] mod tests { use super::*; fn context(window: u64, app: &str) -> Option { Some(Context { session: "one".into(), window, app_id: app.into(), }) } #[test] fn isolates_windows_restores_closed_apps_and_survives_service_restart() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("preferences.json"); let rime = Profile { schema: "luna_pinyin_simp".into(), ascii: false, }; let english = Profile { ascii: true, ..rime.clone() }; let other = Profile { schema: "luna_pinyin".into(), ascii: false, }; let mut p = Profiles::open(&path, rime.schema.clone()).unwrap(); assert_eq!(p.focus(context(1, "QQ")), rime); p.remember(english.clone()).unwrap(); assert_eq!(p.focus(context(2, "QQ")), english); p.remember(other.clone()).unwrap(); assert_eq!(p.focus(context(1, "QQ")), english); assert_eq!(p.focus(context(3, "teams")), rime); drop(p); let mut p = Profiles::open(&path, rime.schema.clone()).unwrap(); assert_eq!(p.focus(context(1, "QQ")), english); assert_eq!(p.focus(context(2, "QQ")), other); assert_eq!(p.focus(context(4, "QQ")), other); let mut fresh = context(1, "QQ").unwrap(); fresh.session = "two".into(); assert_eq!(p.focus(Some(fresh)), other); assert_eq!(p.focus(None), rime); assert_eq!( std::fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600 ); } }