186 lines
5.9 KiB
Rust
186 lines
5.9 KiB
Rust
//! 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<String, Profile>,
|
|
#[serde(default)]
|
|
windows: BTreeMap<String, Profile>,
|
|
}
|
|
|
|
pub struct Profiles {
|
|
path: PathBuf,
|
|
default: Profile,
|
|
stored: Stored,
|
|
dirty: bool,
|
|
pub context: Option<Context>,
|
|
pub current: Profile,
|
|
}
|
|
impl Profiles {
|
|
pub fn open(path: &Path, schema: String) -> Result<Self, String> {
|
|
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<Context>) -> 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<Context> {
|
|
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
|
|
);
|
|
}
|
|
}
|