Fix config upgrade failures, save races, lock confirmation and release validation (#23)

* Coordinate config writes and gate releases on desktop validation

* Use toolkit rc.2 with painting and session lock fixes

* Ignore retired desktop widget settings in existing configs
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-11 12:15:28 +01:00
1 parent a447136b01
commit 91e2f227d2
18 files changed
+536 -155

No files matched your search

+19 -29
View File
@@ -1,5 +1,5 @@
use std::fs;
use std::io::{Read, Write};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use ferese_config::Document;
use serde_json::Value;
pub const LIMIT: usize = 60 * 1024;
pub const LIMIT: usize = ferese_config::file::LIMIT;
#[derive(Clone, Debug)]
pub struct Snapshot {
@@ -200,34 +200,11 @@ fn save_with(
validate: impl FnOnce(&Path) -> Result<(), String>,
) -> Result<Snapshot, String> {
let snapshot = Snapshot::parse(desired.to_owned())?;
let unchanged = || -> Result<(), String> {
if Snapshot::read(path)?.source != expected {
return Err("Your config changed in another app. Reload settings before saving.".into());
}
Ok(())
};
unchanged()?;
let parent = path.parent().ok_or("Invalid configuration path.")?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let mut staged = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
if let Ok(metadata) = fs::metadata(path) {
staged
.as_file()
.set_permissions(metadata.permissions())
.map_err(|e| e.to_string())?;
let file = ferese_config::file::ConfigFile::open(path)?;
if file.source() != expected {
return Err("Your config changed in another app. Reload settings before saving.".into());
}
staged.write_all(desired.as_bytes()).map_err(|e| e.to_string())?;
staged.as_file().sync_all().map_err(|e| e.to_string())?;
validate(staged.path())?;
unchanged()?;
// Keep the last working source, including comments and unknown keys.
let mut backup = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
backup.write_all(expected.as_bytes()).map_err(|e| e.to_string())?;
backup.as_file().sync_all().map_err(|e| e.to_string())?;
backup
.persist(path.with_extension("kdl.settings-backup"))
.map_err(|e| e.to_string())?;
staged.persist(path).map_err(|e| e.to_string())?;
file.replace(desired, Some(&path.with_extension("kdl.settings-backup")), validate)?;
Ok(snapshot)
}
@@ -293,6 +270,19 @@ window-rule app-id="mine" floating=#true
assert_eq!(fs::read_to_string(&path).unwrap(), "// external edit\n");
}
#[test]
fn validation_holds_the_shared_writer_lock() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
save_with(&path, "", "// settings change", |_| {
let error = ferese_config::file::ConfigFile::open(&path).err().unwrap();
assert!(error.contains("another app"));
Ok(())
})
.unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), "// settings change");
}
#[test]
fn catches_edits_during_validation_and_keeps_a_backup_on_success() {
let dir = tempfile::tempdir().unwrap();
+50 -8
View File
@@ -12,6 +12,13 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
let path = path.to_path_buf();
cosmic::iced::stream::channel(1, async move |mut output| {
let path = match std::path::absolute(path) {
Ok(path) => path,
Err(error) => {
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
return;
}
};
let (send, mut events) = tokio::sync::mpsc::channel(1);
let target = path.clone();
let watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
@@ -30,10 +37,16 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
}
};
if let Err(error) = watcher.watch(
path.parent().unwrap_or(std::path::Path::new(".")),
RecursiveMode::NonRecursive,
) {
let directory = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
// On a fresh install the directory may not exist until the first save.
// Install its watch now so later saves do not leave this subscription dead.
let watch = std::fs::create_dir_all(directory)
.map_err(notify::Error::io)
.and_then(|()| watcher.watch(directory, RecursiveMode::NonRecursive));
if let Err(error) = watch {
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
return;
}
@@ -50,10 +63,13 @@ pub fn changes(path: &Path) -> impl Stream<Item = Message> + use<> {
if events.recv().await.is_none() {
return;
}
while tokio::time::timeout(Duration::from_millis(150), events.recv())
.await
.is_ok()
{}
loop {
match tokio::time::timeout(Duration::from_millis(150), events.recv()).await {
Ok(Some(())) => (),
Ok(None) => return,
Err(_) => break,
}
}
}
})
}
@@ -64,6 +80,32 @@ mod tests {
use super::*;
#[test]
fn watches_first_save_when_the_config_directory_does_not_exist() {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let directory = tempfile::tempdir_in(".").unwrap();
let path = directory.path().join("ferese/config.kdl");
let stream = changes(&path);
pin_mut!(stream);
assert!(matches!(stream.next().await, Some(Message::ExternalConfig(Ok(_)))));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let source = "animations { speed 0.75; }";
std::fs::write(&path, source).unwrap();
match tokio::time::timeout(Duration::from_secs(3), stream.next())
.await
.unwrap()
.unwrap()
{
Message::ExternalConfig(Ok(snapshot)) => assert_eq!(snapshot.source, source),
_ => panic!("first save was not observed"),
}
});
}
#[test]
fn watches_atomic_replacement_and_retains_subscription_after_bad_kdl() {
tokio::runtime::Builder::new_current_thread()