698 lines
28 KiB
Rust
698 lines
28 KiB
Rust
//! Filesystem events wake the loop; only pending edits need a debounce timer.
|
|
#[cfg(test)]
|
|
use std::io::Read;
|
|
use std::path::PathBuf;
|
|
#[cfg(test)]
|
|
use std::sync::mpsc;
|
|
#[cfg(test)]
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[cfg(test)]
|
|
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
|
|
|
#[cfg(test)]
|
|
fn read_source(path: &std::path::Path) -> Result<String, String> {
|
|
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
|
let mut source = String::new();
|
|
file.take(60 * 1024 + 1)
|
|
.read_to_string(&mut source)
|
|
.map_err(|e| e.to_string())?;
|
|
if source.len() > 60 * 1024 {
|
|
return Err("configuration exceeds the 60 KiB live-reload limit".into());
|
|
}
|
|
Ok(source)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) struct ConfigMonitor {
|
|
_watcher: RecommendedWatcher,
|
|
events: mpsc::Receiver<()>,
|
|
path: PathBuf,
|
|
dirty: Option<Instant>,
|
|
observed: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl ConfigMonitor {
|
|
#[cfg(test)]
|
|
pub(crate) fn new(path: PathBuf) -> notify::Result<Self> {
|
|
Self::with_wakeup(path, None)
|
|
}
|
|
|
|
pub(crate) fn with_wakeup(
|
|
path: PathBuf,
|
|
wakeup: Option<smithay::reexports::calloop::LoopSignal>,
|
|
) -> notify::Result<Self> {
|
|
let (sender, events) = mpsc::sync_channel(1);
|
|
let target = path.clone();
|
|
let event_wakeup = wakeup.clone();
|
|
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
|
if let Ok(event) = event
|
|
&& !event.kind.is_access()
|
|
&& event.paths.iter().any(|p| p == &target || target.starts_with(p))
|
|
{
|
|
let _ = sender.try_send(());
|
|
if let Some(wakeup) = &event_wakeup {
|
|
wakeup.wakeup();
|
|
}
|
|
}
|
|
})?;
|
|
// Watch the directory, not the inode: atomic editor saves replace it.
|
|
let mut directory = path.parent().unwrap_or(std::path::Path::new("."));
|
|
while !directory.is_dir() {
|
|
directory = directory.parent().unwrap_or(std::path::Path::new("."));
|
|
}
|
|
watcher.watch(
|
|
directory,
|
|
if Some(directory) == path.parent() {
|
|
RecursiveMode::NonRecursive
|
|
} else {
|
|
RecursiveMode::Recursive
|
|
},
|
|
)?;
|
|
let initial_dirty = path.is_file().then(Instant::now);
|
|
if initial_dirty.is_some()
|
|
&& let Some(wakeup) = wakeup
|
|
{
|
|
wakeup.wakeup();
|
|
}
|
|
Ok(Self {
|
|
_watcher: watcher,
|
|
events,
|
|
path,
|
|
dirty: initial_dirty,
|
|
observed: None,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn next_deadline(&self) -> Option<Instant> {
|
|
self.dirty.map(|since| since + Duration::from_millis(120))
|
|
}
|
|
|
|
pub(crate) fn poll(&mut self, now: Instant) -> Option<Result<String, String>> {
|
|
if self.events.try_iter().next().is_some() {
|
|
self.dirty = Some(now);
|
|
}
|
|
if self
|
|
.dirty
|
|
.is_none_or(|since| now.saturating_duration_since(since) < Duration::from_millis(120))
|
|
{
|
|
return None;
|
|
}
|
|
self.dirty = None;
|
|
let source = match read_source(&self.path) {
|
|
Ok(source) => source,
|
|
Err(error) => {
|
|
return Some(Err(format!(
|
|
"{}: {error}; retaining active config",
|
|
self.path.display()
|
|
)));
|
|
}
|
|
};
|
|
if self.observed.as_ref() == Some(&source) {
|
|
return None;
|
|
}
|
|
self.observed = Some(source.clone());
|
|
Some(Ok(source))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Prepared {
|
|
source: String,
|
|
sections: serde_json::Value,
|
|
runtime: crate::RuntimeConfig,
|
|
candidate: ferese_config::theme::Candidate,
|
|
}
|
|
|
|
impl Prepared {
|
|
fn new(source: String, directory: &std::path::Path) -> Result<Self, String> {
|
|
Self::with_watch(source, directory, &mut Vec::new())
|
|
}
|
|
|
|
fn with_watch(source: String, directory: &std::path::Path, watched: &mut Vec<PathBuf>) -> Result<Self, String> {
|
|
if source.len() > 60 * 1024 {
|
|
return Err("configuration exceeds the 60 KiB live-reload limit".into());
|
|
}
|
|
|
|
let document = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?;
|
|
if let Some(value) = document.get("theme")
|
|
&& let Ok(policy) = serde_json::from_value::<ferese_config::theme::Policy>(value.clone())
|
|
{
|
|
watched.extend(
|
|
[&policy.file, &policy.light.file, &policy.dark.file]
|
|
.into_iter()
|
|
.flatten()
|
|
.chain(policy.custom_themes.values().map(|theme| &theme.file))
|
|
.filter(|path| !path.as_os_str().is_empty())
|
|
.map(|path| ferese_config::theme::theme_path(directory, path)),
|
|
);
|
|
}
|
|
let (_, runtime, candidate) = crate::theme::prepare_document(&document, directory)?;
|
|
let mut sections = document.value().clone();
|
|
if let Some(object) = sections.as_object_mut() {
|
|
object.remove("theme");
|
|
}
|
|
|
|
Ok(Self {
|
|
source,
|
|
sections,
|
|
runtime,
|
|
candidate,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn file_stamps(files: &[PathBuf]) -> Vec<(PathBuf, Option<(std::time::SystemTime, u64)>)> {
|
|
files
|
|
.iter()
|
|
.map(|path| {
|
|
let stamp = path
|
|
.metadata()
|
|
.ok()
|
|
.and_then(|m| m.modified().ok().map(|time| (time, m.len())));
|
|
(path.clone(), stamp)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
type Reply = (u64, std::sync::mpsc::SyncSender<ferese_ipc::Response>);
|
|
struct Request {
|
|
force: bool,
|
|
fallback: Option<String>,
|
|
reply: Option<Reply>,
|
|
}
|
|
|
|
pub(crate) struct Worker {
|
|
requests: std::sync::mpsc::SyncSender<Request>,
|
|
}
|
|
|
|
impl Worker {
|
|
pub(crate) fn new(
|
|
event_loop: &mut calloop::EventLoop<'static, crate::Ferese>,
|
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
|
let (requests, receiver) = std::sync::mpsc::sync_channel::<Request>(8);
|
|
let (results, events) = calloop::channel::channel::<(Result<Prepared, String>, Vec<PathBuf>, Option<Reply>)>();
|
|
event_loop.handle().insert_source(events, |event, _, state| {
|
|
if let calloop::channel::Event::Msg((result, watched, reply)) = event {
|
|
state.theme_engine.watch_files(&watched);
|
|
let result = result.and_then(|prepared| state.apply_prepared_config(prepared));
|
|
if let Err(error) = &result {
|
|
state.theme_engine.reject(error.clone());
|
|
}
|
|
|
|
if let Some((id, reply)) = reply {
|
|
let response = match result {
|
|
Ok(()) => ferese_ipc::Response::success(id, serde_json::json!({})),
|
|
Err(error) => ferese_ipc::Response::error(id, "invalid_config", error),
|
|
};
|
|
let _ = reply.try_send(response);
|
|
}
|
|
}
|
|
})?;
|
|
std::thread::Builder::new()
|
|
.name("ferese-config".into())
|
|
.spawn(move || {
|
|
let mut cached: Option<Prepared> = None;
|
|
let mut stamps = Vec::new();
|
|
for request in receiver {
|
|
let started = std::time::Instant::now();
|
|
let path = crate::config::config_path();
|
|
let directory = path
|
|
.as_ref()
|
|
.and_then(|p| p.parent())
|
|
.unwrap_or(std::path::Path::new("."));
|
|
let mut watched = Vec::new();
|
|
let result = path
|
|
.as_ref()
|
|
.map_or_else(
|
|
|| request.fallback.ok_or_else(|| "No configuration available".into()),
|
|
|path| {
|
|
if path.exists() || !request.force {
|
|
crate::theme::read_source(path)
|
|
} else {
|
|
Ok(String::new())
|
|
}
|
|
},
|
|
)
|
|
.and_then(|source| {
|
|
// Watcher and explicit reloads share the same cache.
|
|
// Auto mode still resolves time and system appearance.
|
|
if let Some(previous) = &cached
|
|
&& previous.source == source
|
|
&& previous.candidate.policy.mode != ferese_config::theme::Mode::Auto
|
|
&& file_stamps(&previous.candidate.files) == stamps
|
|
{
|
|
return Ok(previous.clone());
|
|
}
|
|
|
|
let prepared = Prepared::with_watch(source, directory, &mut watched)?;
|
|
stamps = file_stamps(&prepared.candidate.files);
|
|
cached = Some(prepared.clone());
|
|
Ok(prepared)
|
|
});
|
|
tracing::debug!(
|
|
elapsed_us = started.elapsed().as_micros() as u64,
|
|
"configuration prepared on worker"
|
|
);
|
|
if results.send((result, watched, request.reply)).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
})?;
|
|
Ok(Self { requests })
|
|
}
|
|
}
|
|
|
|
impl crate::Ferese {
|
|
pub(crate) fn queue_config_reload(&self, force: bool, reply: Option<Reply>) -> Result<(), String> {
|
|
let request = Request {
|
|
force,
|
|
fallback: self.config_source.clone(),
|
|
reply,
|
|
};
|
|
let worker = self.config_worker.as_ref().ok_or("configuration worker unavailable")?;
|
|
if let Err(error) = worker.requests.try_send(request) {
|
|
let (std::sync::mpsc::TrySendError::Full(request) | std::sync::mpsc::TrySendError::Disconnected(request)) =
|
|
error;
|
|
if let Some((id, reply)) = request.reply {
|
|
let _ = reply.try_send(ferese_ipc::Response::error(
|
|
id,
|
|
"reload_busy",
|
|
"Configuration reload queue is full",
|
|
));
|
|
}
|
|
return Err("Configuration reload queue is full".into());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_prepared_config(&mut self, prepared: Prepared) -> Result<(), String> {
|
|
let Prepared {
|
|
source,
|
|
sections,
|
|
mut runtime,
|
|
candidate,
|
|
} = prepared;
|
|
// Identical watcher/IPC reloads do not restart a theme transition.
|
|
if self.config_source.as_ref() == Some(&source)
|
|
&& candidate.theme == self.theme_engine.live.theme
|
|
&& candidate.families == self.theme_engine.live.families
|
|
&& candidate.policy.mode == self.theme_engine.live.mode
|
|
&& candidate.warnings == self.theme_engine.live.warnings
|
|
&& candidate.fallback_note == self.theme_engine.live.fallback_note
|
|
&& self.theme_engine.matches_files(&candidate.files)
|
|
&& self.theme_engine.live.error.is_none()
|
|
{
|
|
if let Err(error) = self.theme_engine.arm_clock(candidate.next_transition) {
|
|
tracing::warn!(%error, "cannot arm appearance schedule");
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let wallpaper = runtime.wallpaper.clone();
|
|
runtime.wallpaper = self.wallpaper.configuration().clone();
|
|
let previous_theme = self.theme_settings;
|
|
if self.config_sections.is_none() {
|
|
self.config_sections = self
|
|
.config_source
|
|
.as_deref()
|
|
.and_then(|s| ferese_config::Document::parse(s).ok())
|
|
.map(|document| {
|
|
let mut value = document.value().clone();
|
|
if let Some(object) = value.as_object_mut() {
|
|
object.remove("theme");
|
|
}
|
|
value
|
|
});
|
|
}
|
|
if self.config_sections.as_ref() != Some(§ions) {
|
|
self.apply_runtime_config(runtime, §ions)?;
|
|
}
|
|
|
|
self.overview
|
|
.set_font_family(candidate.theme.tokens.typography.font_family.clone());
|
|
self.theme_settings = previous_theme;
|
|
self.accept_theme(candidate, wallpaper);
|
|
self.config_sections = Some(sections);
|
|
self.config_source = Some(source.clone());
|
|
self.shell_resources.retain(|resource| {
|
|
if let Ok(shell) = resource.upgrade() {
|
|
if smithay::reexports::wayland_server::Resource::version(&shell) >= 2 {
|
|
crate::shell_control::send_shell_config(&shell, &source);
|
|
}
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
});
|
|
self.ipc_events.config_revision = self.ipc_events.config_revision.wrapping_add(1);
|
|
tracing::info!("configuration reloaded live");
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> {
|
|
let directory = crate::config::config_path()
|
|
.and_then(|p| p.parent().map(ToOwned::to_owned))
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
let prepared = Prepared::new(source, &directory)?;
|
|
self.apply_prepared_config(prepared)
|
|
}
|
|
|
|
pub(crate) fn reload_config(&mut self) -> Result<(), String> {
|
|
let path = crate::config::config_path().ok_or("config directory is unavailable")?;
|
|
self.reload_config_source(crate::theme::read_source(&path)?)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
struct Directory(PathBuf);
|
|
impl Directory {
|
|
fn new() -> Self {
|
|
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"ferese-reload-test-{}-{}",
|
|
std::process::id(),
|
|
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
|
));
|
|
std::fs::create_dir(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
}
|
|
|
|
impl Drop for Directory {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn worker_reload_applies_once_and_rejects_invalid_configuration() {
|
|
const CHILD: &str = "FERESE_WORKER_RELOAD_TEST_CHILD";
|
|
if std::env::var_os(CHILD).is_none() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let directory = Directory::new();
|
|
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
|
.args([
|
|
"--exact",
|
|
"reload::tests::worker_reload_applies_once_and_rejects_invalid_configuration",
|
|
"--nocapture",
|
|
])
|
|
.env(CHILD, "1")
|
|
.env("XDG_RUNTIME_DIR", &directory.0)
|
|
.env("XDG_CONFIG_HOME", &directory.0)
|
|
.env_remove("FERESE_SOCKET")
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(
|
|
result.status.success(),
|
|
"{}\n{}",
|
|
String::from_utf8_lossy(&result.stdout),
|
|
String::from_utf8_lossy(&result.stderr)
|
|
);
|
|
return;
|
|
}
|
|
|
|
let (_, runtime, _) = crate::theme::prepare("", std::path::Path::new(".")).unwrap();
|
|
let mut event_loop = calloop::EventLoop::try_new().unwrap();
|
|
let display = smithay::reexports::wayland_server::Display::new().unwrap();
|
|
let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap();
|
|
let path = crate::config::config_path().unwrap();
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
let valid = "animations { speed 0.5; }";
|
|
std::fs::write(&path, valid).unwrap();
|
|
for (index, source) in [Some(valid), Some(valid), Some("animations { speed -1.0; }"), None]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
if let Some(source) = source {
|
|
std::fs::write(&path, source).unwrap();
|
|
} else {
|
|
std::fs::remove_file(&path).unwrap();
|
|
}
|
|
let before = state.theme_engine.revision;
|
|
let (reply, received) = std::sync::mpsc::sync_channel(1);
|
|
state.queue_config_reload(index == 1, Some((42, reply))).unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
let response = loop {
|
|
event_loop.dispatch(Duration::from_millis(10), &mut state).unwrap();
|
|
if let Ok(response) = received.try_recv() {
|
|
break response;
|
|
}
|
|
assert!(Instant::now() < deadline, "worker did not reply");
|
|
};
|
|
assert_eq!(response.id, 42);
|
|
assert_eq!(response.error.is_some(), source != Some(valid));
|
|
assert_eq!(
|
|
state.animation_duration(Duration::from_millis(100)),
|
|
Duration::from_millis(200)
|
|
);
|
|
assert_eq!(state.config_source.as_deref(), Some(valid));
|
|
if source == Some(valid) && before > 0 {
|
|
assert_eq!(state.theme_engine.revision, before);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn live_reload_keeps_focus_effect_independent_of_theme_transitions() {
|
|
if std::env::var_os("FERESE_DIM_RELOAD_TEST_CHILD").is_none() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let directory = Directory::new();
|
|
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
|
.args([
|
|
"--exact",
|
|
"reload::tests::live_reload_keeps_focus_effect_independent_of_theme_transitions",
|
|
"--nocapture",
|
|
])
|
|
.env("FERESE_DIM_RELOAD_TEST_CHILD", "1")
|
|
.env("XDG_RUNTIME_DIR", &directory.0)
|
|
.env("XDG_CONFIG_HOME", &directory.0)
|
|
.env_remove("FERESE_SOCKET")
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"{}\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
return;
|
|
}
|
|
let source = "appearance { focus-effect { inactive-opacity 0.8; inactive-dim 0.25; }; }";
|
|
let directory = Directory::new();
|
|
let (_, runtime, candidate) = crate::theme::prepare(source, &directory.0).unwrap();
|
|
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
|
let display = smithay::reexports::wayland_server::Display::new().unwrap();
|
|
let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap();
|
|
state.config_source = Some(source.into());
|
|
state.theme_engine.live.theme = candidate.theme.clone();
|
|
state.theme_engine.live.presented = candidate.theme;
|
|
|
|
for inactive_opacity in [1.0, 0.75, 0.9] {
|
|
let source =
|
|
format!("appearance {{ focus-effect {{ inactive-opacity {inactive_opacity}; inactive-dim 0.25; }}; }}");
|
|
state.reload_config_source(source).unwrap();
|
|
assert_eq!(state.focus_effect.inactive_opacity, inactive_opacity);
|
|
let (alpha, dim) = state.focus_effect.resolve(0.0, true, 0.0);
|
|
assert_eq!(alpha, inactive_opacity as f32);
|
|
assert_eq!(dim, 0.25);
|
|
}
|
|
for enabled in [false, true] {
|
|
state.reload_config_source(format!("appearance {{ focus-effect {{ enabled #{enabled}; active-opacity 0.95; inactive-opacity 0.8; inactive-dim 0.25; }}; }}")).unwrap();
|
|
assert_eq!(state.focus_effect.enabled, enabled);
|
|
assert_eq!(state.focus_effect.inactive_opacity, 0.8);
|
|
assert_eq!(state.focus_effect.inactive_dim, 0.25);
|
|
assert_eq!(
|
|
state.focus_effect.resolve(0.0, true, 0.0),
|
|
if enabled { (0.8, 0.25) } else { (1.0, 0.0) }
|
|
);
|
|
}
|
|
let previous = state.focus_effect;
|
|
assert!(
|
|
state
|
|
.reload_config_source("appearance { focus-effect { active-opacity 1.1; }; }".into())
|
|
.is_err()
|
|
);
|
|
assert_eq!(state.focus_effect, previous);
|
|
state
|
|
.reload_config_source("appearance { focus-effect { inactive-dim 0; }; }; theme { mode \"light\"; }".into())
|
|
.unwrap();
|
|
state.poll_theme();
|
|
assert_eq!(state.focus_effect.inactive_dim, 0.0);
|
|
}
|
|
|
|
/// The status endpoint must be able to tell "the live service is running
|
|
/// with the settings it started with" from "the file was edited", so the
|
|
/// desired X11 configuration has to follow live reloads.
|
|
#[test]
|
|
fn xwayland_configuration_follows_live_reloads() {
|
|
const CHILD: &str = "FERESE_XWAYLAND_RELOAD_TEST_CHILD";
|
|
if std::env::var_os(CHILD).is_none() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let directory = Directory::new();
|
|
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
|
.args([
|
|
"--exact",
|
|
"reload::tests::xwayland_configuration_follows_live_reloads",
|
|
"--nocapture",
|
|
])
|
|
.env(CHILD, "1")
|
|
.env("XDG_RUNTIME_DIR", &directory.0)
|
|
.env("XDG_CONFIG_HOME", &directory.0)
|
|
.env_remove("FERESE_SOCKET")
|
|
.output()
|
|
.unwrap();
|
|
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
|
|
return;
|
|
}
|
|
|
|
let directory = Directory::new();
|
|
let (_, runtime, _) = crate::theme::prepare("xwayland { enabled #false; }", &directory.0).unwrap();
|
|
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
|
let display = smithay::reexports::wayland_server::Display::new().unwrap();
|
|
let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap();
|
|
|
|
assert!(
|
|
!state.xwayland_config.enabled,
|
|
"the session starts with the configured value"
|
|
);
|
|
|
|
state
|
|
.reload_config_source("xwayland { enabled #true; startup \"eager\"; }".into())
|
|
.unwrap();
|
|
|
|
assert!(
|
|
state.xwayland_config.enabled,
|
|
"a reload must update the desired configuration, or restart_required can never become true"
|
|
);
|
|
assert_eq!(
|
|
state.xwayland_config.startup,
|
|
crate::config::XwaylandStartup::Eager,
|
|
"every field follows the reload, not just the enabled flag"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_monitor_reload_keeps_last_good_runtime_configuration() {
|
|
const CHILD: &str = "FERESE_OUTPUT_RELOAD_TEST_CHILD";
|
|
if std::env::var_os(CHILD).is_none() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let directory = Directory::new();
|
|
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
|
.args([
|
|
"--exact",
|
|
"reload::tests::invalid_monitor_reload_keeps_last_good_runtime_configuration",
|
|
"--nocapture",
|
|
])
|
|
.env(CHILD, "1")
|
|
.env("XDG_RUNTIME_DIR", &directory.0)
|
|
.env("XDG_CONFIG_HOME", &directory.0)
|
|
.env_remove("FERESE_SOCKET")
|
|
.output()
|
|
.unwrap();
|
|
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
|
|
return;
|
|
}
|
|
let directory = Directory::new();
|
|
let source = "output-profile mobile { output eDP-1 scale=1.75; }";
|
|
let (_, runtime, _) = crate::theme::prepare(source, &directory.0).unwrap();
|
|
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
|
let display = smithay::reexports::wayland_server::Display::new().unwrap();
|
|
let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap();
|
|
state.config_source = Some(source.into());
|
|
let accepted = state.output_profiles.clone();
|
|
for invalid in [
|
|
"output-profile bad { output eDP-1 scale=0; }",
|
|
"output-profile bad layout=\"mirror\" { output eDP-1; }",
|
|
"output-profile mobile { output eDP-1; }\noutput-profile mobile { output DP-1; }",
|
|
] {
|
|
assert!(state.reload_config_source(invalid.into()).is_err());
|
|
assert_eq!(state.output_profiles, accepted);
|
|
assert_eq!(state.config_source.as_deref(), Some(source));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_validation_rejects_bad_edits_before_application() {
|
|
for source in [
|
|
"animations {\n speed 0\n}\n",
|
|
r##"theme {
|
|
focus-ring {
|
|
gradient {
|
|
from "bad"
|
|
to "#ffffff"
|
|
}
|
|
}
|
|
}
|
|
"##,
|
|
"status {\n low-battery-threshold \"bad\"\n}\n",
|
|
"layout {\n inner-gap -1\n}\n",
|
|
] {
|
|
assert!(
|
|
crate::config::Config::parse_source(source)
|
|
.and_then(|config| config.runtime_config())
|
|
.is_err()
|
|
);
|
|
}
|
|
assert!(
|
|
crate::config::Config::parse_source("animations {\n speed 0.75\n}\n")
|
|
.unwrap()
|
|
.runtime_config()
|
|
.is_ok()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn directory_watch_detects_atomic_save_and_does_not_read_during_idle() {
|
|
let directory = Directory::new();
|
|
let path = directory.0.join("config.kdl");
|
|
std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap();
|
|
let mut monitor = ConfigMonitor::new(path.clone()).unwrap();
|
|
monitor.dirty = Some(Instant::now() - Duration::from_secs(1));
|
|
assert!(monitor.poll(Instant::now()).unwrap().is_ok());
|
|
// With no event, even a deliberately missing path is never read.
|
|
let original_path = monitor.path.clone();
|
|
monitor.path = directory.0.join("missing");
|
|
assert!(monitor.poll(Instant::now()).is_none());
|
|
monitor.path = original_path;
|
|
let temporary = directory.0.join("config.kdl.new");
|
|
std::fs::write(&temporary, "animations {\n speed 0.75\n}\n").unwrap();
|
|
std::fs::rename(&temporary, &path).unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
loop {
|
|
if let Some(result) = monitor.poll(Instant::now()) {
|
|
assert_eq!(result.unwrap(), "animations {\n speed 0.75\n}\n");
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "atomic save notification never settled");
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn source_size_limit_is_bounded_and_debounce_coalesces_edits() {
|
|
let directory = Directory::new();
|
|
let path = directory.0.join("config.kdl");
|
|
std::fs::write(&path, "x".repeat(60 * 1024 + 1)).unwrap();
|
|
assert!(read_source(&path).is_err());
|
|
std::fs::write(&path, "animations {\n speed 1\n}\n").unwrap();
|
|
let mut monitor = ConfigMonitor::new(path).unwrap();
|
|
let now = Instant::now();
|
|
monitor.dirty = Some(now);
|
|
assert_eq!(monitor.next_deadline(), Some(now + Duration::from_millis(120)));
|
|
assert!(monitor.poll(now + Duration::from_millis(119)).is_none());
|
|
assert!(monitor.poll(now + Duration::from_millis(121)).unwrap().is_ok());
|
|
assert_eq!(monitor.next_deadline(), None);
|
|
}
|
|
}
|