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:
1 parent
a447136b01
commit
91e2f227d2
18 files changed
+536
-155
No files matched your search
@@ -354,22 +354,20 @@ impl Locker {
|
||||
}
|
||||
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::SessionLock(event))) if !self.preview => {
|
||||
match event {
|
||||
wayland::SessionLockEvent::Locked => {
|
||||
if self.confirmation.observe() {
|
||||
use std::io::Write;
|
||||
if std::env::var_os("FERESE_LOCK_READY").is_some() {
|
||||
let _ = writeln!(std::io::stdout().lock(), "FERESE_LOCKED");
|
||||
}
|
||||
}
|
||||
if !self.started {
|
||||
self.started = true;
|
||||
return Task::batch(
|
||||
self.outputs
|
||||
.iter()
|
||||
.map(|(output, id)| session_lock::get_lock_surface(*id, output.clone())),
|
||||
);
|
||||
wayland::SessionLockEvent::Locked if self.confirmation.observe(&event) => {
|
||||
use std::io::Write;
|
||||
if std::env::var_os("FERESE_LOCK_READY").is_some() {
|
||||
let _ = writeln!(std::io::stdout().lock(), "FERESE_LOCKED");
|
||||
}
|
||||
}
|
||||
wayland::SessionLockEvent::Requested if !self.started => {
|
||||
self.started = true;
|
||||
return Task::batch(
|
||||
self.outputs
|
||||
.iter()
|
||||
.map(|(output, id)| session_lock::get_lock_surface(*id, output.clone())),
|
||||
);
|
||||
}
|
||||
wayland::SessionLockEvent::Focused(..) => return focus(),
|
||||
wayland::SessionLockEvent::Unlocked if self.state == AuthState::Unlocking => {
|
||||
return iced::exit();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Startup handshake: the invoking command exits successfully only after the
|
||||
//! compositor's confirmation. The UI child remains alive for authentication.
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
@@ -43,6 +43,10 @@ pub fn daemonize() -> Result<(), String> {
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Starting locker {}: {e}", executable.display()))?;
|
||||
confirm(&mut child)
|
||||
}
|
||||
|
||||
fn confirm(child: &mut Child) -> Result<(), String> {
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::spawn(move || {
|
||||
@@ -53,44 +57,65 @@ pub fn daemonize() -> Result<(), String> {
|
||||
});
|
||||
match rx.recv_timeout(Duration::from_secs(15)) {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => {
|
||||
let _ = child.wait();
|
||||
Err("locker exited without confirming protection".into())
|
||||
}
|
||||
Ok(false) => match child.try_wait() {
|
||||
Ok(Some(_)) => Err("locker exited without confirming protection".into()),
|
||||
_ => Err("locker did not confirm protection; the locker was left running".into()),
|
||||
},
|
||||
Err(_) => Err("lock confirmation timed out; the locker was left running".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// libcosmic revision 03d7dcb emits one synthetic Locked on the request and
|
||||
/// another from SCTK's actual protocol callback. Requiring both is essential:
|
||||
/// never release swayidle's delay inhibitor on the synthetic notification.
|
||||
/// Re-audit iced/winit's session_lock Action::Lock and SessionLockHandler when
|
||||
/// updating libcosmic. A missing callback times out closed, never signals ready.
|
||||
/// Readiness follows the compositor's protocol confirmation, never the request.
|
||||
#[derive(Default)]
|
||||
pub struct Confirmation {
|
||||
events: u8,
|
||||
confirmed: bool,
|
||||
}
|
||||
impl Confirmation {
|
||||
pub fn observe(&mut self) -> bool {
|
||||
self.events = self.events.saturating_add(1);
|
||||
self.events == 2
|
||||
pub fn observe(&mut self, event: &cosmic::iced::event::wayland::SessionLockEvent) -> bool {
|
||||
if !matches!(event, cosmic::iced::event::wayland::SessionLockEvent::Locked) || self.confirmed {
|
||||
return false;
|
||||
}
|
||||
self.confirmed = true;
|
||||
true
|
||||
}
|
||||
pub fn confirmed(&self) -> bool {
|
||||
self.events >= 2
|
||||
self.confirmed
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthetic_event_does_not_confirm_a_lock() {
|
||||
fn invalid_confirmation_returns_without_waiting_for_or_killing_the_locker() {
|
||||
for message in ["unexpected", "FERESE_LOCKED"] {
|
||||
let mut child = Command::new("sh")
|
||||
.args(["-c", "printf '%s\\n' \"$1\"; exec sleep 3", "lock-fixture", message])
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let result = confirm(&mut child);
|
||||
let running = child.try_wait().unwrap().is_none();
|
||||
if running {
|
||||
child.kill().unwrap();
|
||||
}
|
||||
child.wait().unwrap();
|
||||
assert_eq!(result.is_ok(), message == "FERESE_LOCKED");
|
||||
assert!(running, "confirmation must leave the locker running");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_compositor_confirmation_marks_the_lock_ready_once() {
|
||||
use cosmic::iced::event::wayland::SessionLockEvent::{Finished, Locked, NotSupported, Requested};
|
||||
let mut c = Confirmation::default();
|
||||
assert!(!c.confirmed());
|
||||
assert!(!c.observe());
|
||||
assert!(!c.confirmed());
|
||||
assert!(c.observe());
|
||||
for event in [Requested, Requested, Finished, NotSupported] {
|
||||
assert!(!c.observe(&event));
|
||||
assert!(!c.confirmed());
|
||||
}
|
||||
assert!(c.observe(&Locked));
|
||||
assert!(c.confirmed());
|
||||
assert!(!c.observe());
|
||||
assert!(!c.observe(&Locked));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ ferese-theme-client = { path = "../../crates/ferese-theme-client" }
|
||||
ferese-theme = { path = "../../crates/ferese-theme" }
|
||||
ferese-config = { path = "../../crates/ferese-config" }
|
||||
libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] }
|
||||
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.3-rc.1" }
|
||||
iced_accessibility.workspace = true
|
||||
ferese-ipc = { path = "../../crates/ferese-ipc" }
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -18,7 +18,7 @@ libc.workspace = true
|
||||
# tiny-skia to avoid a second GPU initialization during desktop startup.
|
||||
libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
|
||||
freedesktop-desktop-entry = { version = "0.8.3", default-features = false }
|
||||
iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.3-rc.1" }
|
||||
iced_accessibility.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
Reference in new issue
Block a user