Fix config and IPC lifecycle issues and remove obsolete code
This commit is contained in:
1 parent
8007f7a107
commit
5d9673aad3
14 files changed
+715
-745
No files matched your search
+10
-154
@@ -1,121 +1,5 @@
|
||||
//! 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 {
|
||||
@@ -369,6 +253,7 @@ impl crate::Ferese {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
@@ -663,45 +548,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_watch_detects_atomic_save_and_does_not_read_during_idle() {
|
||||
fn source_size_limit_uses_the_production_reader_and_parser() {
|
||||
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);
|
||||
let source = format!("//{}", "x".repeat(60 * 1024 - 2));
|
||||
std::fs::write(&path, &source).unwrap();
|
||||
assert_eq!(crate::config::read_source(&path).unwrap(), source);
|
||||
assert!(Prepared::new(source.clone(), &directory.0).is_ok());
|
||||
let oversized = source + "x";
|
||||
std::fs::write(&path, &oversized).unwrap();
|
||||
assert!(crate::config::read_source(&path).is_err());
|
||||
assert!(Prepared::new(oversized, &directory.0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ async fn listen(
|
||||
let mut signals = proxy.receive_signal("PrepareForSleep").await?;
|
||||
// AddMatch succeeds even when logind is absent. Only call the listener
|
||||
// recovered once the service is present, and retry if its owner changes.
|
||||
connection
|
||||
let owner = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.DBus"),
|
||||
"/org/freedesktop/DBus",
|
||||
@@ -236,7 +236,7 @@ async fn listen(
|
||||
|
||||
future::race(
|
||||
async {
|
||||
owners.next().await;
|
||||
owner_changed_after(&mut owners, owner.recv_position()).await;
|
||||
Ok(MonitorExit::Reconnect)
|
||||
},
|
||||
async {
|
||||
@@ -253,5 +253,16 @@ async fn listen(
|
||||
.await
|
||||
}
|
||||
|
||||
// Owner changes queued during subscription setup are already reflected in the
|
||||
// GetNameOwner reply. Only a later change interrupts this recovered listener.
|
||||
async fn owner_changed_after(owners: &mut zbus::proxy::OwnerChangedStream<'_>, baseline: zbus::message::Sequence) {
|
||||
use zbus::export::ordered_stream::OrderedStreamExt;
|
||||
while let Some((sequence, _)) = owners.next_before(None).await.into_tuple() {
|
||||
if sequence > baseline {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -247,3 +247,56 @@ fn shutdown_interrupts_connection_authentication() {
|
||||
};
|
||||
stop_and_join(monitor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_acquired_before_subscription_baseline_is_not_a_second_outage() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"resume::tests::ownership_acquired_before_subscription_baseline_is_not_a_second_outage",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let bus = Bus::new();
|
||||
let (connection, mut owners, manager, baseline) = future::block_on(async {
|
||||
let connection = zbus::connection::Builder::address(bus.address.as_str())
|
||||
.unwrap()
|
||||
.build()
|
||||
.await
|
||||
.unwrap();
|
||||
let proxy = zbus::Proxy::new(
|
||||
&connection,
|
||||
"org.freedesktop.login1",
|
||||
"/org/freedesktop/login1",
|
||||
"org.freedesktop.login1.Manager",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let owners = proxy.receive_owner_changed().await.unwrap();
|
||||
// Force acquisition after AddMatch but before the listener's baseline.
|
||||
let manager = bus.manager();
|
||||
let reply = connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.DBus"),
|
||||
"/org/freedesktop/DBus",
|
||||
Some("org.freedesktop.DBus"),
|
||||
"GetNameOwner",
|
||||
&("org.freedesktop.login1",),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(connection, owners, manager, reply.recv_position())
|
||||
});
|
||||
let (sender, events) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
// Keep the same connection alive so receive sequences remain comparable.
|
||||
let _connection = connection;
|
||||
future::block_on(owner_changed_after(&mut owners, baseline));
|
||||
sender.send(()).unwrap();
|
||||
});
|
||||
assert!(
|
||||
events.recv_timeout(Duration::from_millis(100)).is_err(),
|
||||
"queued acquisition must not interrupt the recovered listener"
|
||||
);
|
||||
manager.release_name("org.freedesktop.login1").unwrap();
|
||||
events.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
worker.join().unwrap();
|
||||
}
|
||||
+89
-25
@@ -149,6 +149,22 @@ pub(crate) struct Engine {
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
fn files_changed(&mut self, now: Instant) -> bool {
|
||||
if self
|
||||
.events
|
||||
.as_ref()
|
||||
.is_some_and(|events| events.try_iter().next().is_some())
|
||||
{
|
||||
self.dirty = Some(now + DEBOUNCE);
|
||||
}
|
||||
if self.dirty.is_some_and(|deadline| now >= deadline) {
|
||||
self.dirty = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matches_files(&self, files: &[PathBuf]) -> bool {
|
||||
self.active_files == files
|
||||
}
|
||||
@@ -357,21 +373,7 @@ pub(crate) fn init(
|
||||
let _ = monitor.wait();
|
||||
}
|
||||
})?;
|
||||
let watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
if let Ok(event) = event
|
||||
&& !event.kind.is_access()
|
||||
&& event.paths.iter().any(|path| {
|
||||
paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|target| target == path || target.starts_with(path))
|
||||
})
|
||||
{
|
||||
let _ = sender.try_send(());
|
||||
wakeup.wakeup();
|
||||
}
|
||||
})?;
|
||||
let watcher = file_watcher(paths, sender, wakeup)?;
|
||||
state.theme_engine.watcher = Some(watcher);
|
||||
state.theme_engine.events = Some(events);
|
||||
state.theme_engine.active_files = candidate.files.clone();
|
||||
@@ -387,6 +389,28 @@ pub(crate) fn init(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_watcher(
|
||||
paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
sender: mpsc::SyncSender<()>,
|
||||
wakeup: calloop::LoopSignal,
|
||||
) -> notify::Result<RecommendedWatcher> {
|
||||
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
if let Ok(event) = event
|
||||
&& !event.kind.is_access()
|
||||
&& event.paths.iter().any(|path| {
|
||||
paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|target| target == path || target.starts_with(path))
|
||||
})
|
||||
{
|
||||
let _ = sender.try_send(());
|
||||
wakeup.wakeup();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn refresh_theme(&mut self) {
|
||||
if let Err(error) = self.queue_config_reload(true, None) {
|
||||
@@ -477,16 +501,7 @@ impl Ferese {
|
||||
|
||||
pub(crate) fn poll_theme(&mut self) {
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.theme_engine
|
||||
.events
|
||||
.as_ref()
|
||||
.is_some_and(|events| events.try_iter().next().is_some())
|
||||
{
|
||||
self.theme_engine.dirty = Some(now + DEBOUNCE);
|
||||
}
|
||||
if self.theme_engine.dirty.is_some_and(|deadline| now >= deadline) {
|
||||
self.theme_engine.dirty = None;
|
||||
if self.theme_engine.files_changed(now) {
|
||||
self.refresh_theme();
|
||||
}
|
||||
let mut start = false;
|
||||
@@ -618,6 +633,55 @@ impl Ferese {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn directory_watch_detects_repeated_atomic_saves() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
std::fs::write(&path, "// initial").unwrap();
|
||||
let event_loop = calloop::EventLoop::<()>::try_new().unwrap();
|
||||
let (sender, events) = mpsc::sync_channel(1);
|
||||
let mut engine = Engine {
|
||||
events: Some(events),
|
||||
..Engine::default()
|
||||
};
|
||||
engine.watcher = Some(file_watcher(engine.paths.clone(), sender, event_loop.get_signal()).unwrap());
|
||||
engine.watch_files(std::slice::from_ref(&path));
|
||||
for source in ["// first save", "// second save"] {
|
||||
let temporary = path.with_extension("new");
|
||||
std::fs::write(&temporary, source).unwrap();
|
||||
std::fs::rename(&temporary, &path).unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while !engine.files_changed(Instant::now()) {
|
||||
assert!(Instant::now() < deadline, "atomic save notification never settled");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(crate::config::read_source(&path).unwrap(), source);
|
||||
}
|
||||
assert!(!engine.files_changed(Instant::now() + Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_events_debounce_from_the_last_edit_and_stay_idle_without_events() {
|
||||
let (sender, events) = mpsc::sync_channel(1);
|
||||
let mut engine = Engine {
|
||||
events: Some(events),
|
||||
..Engine::default()
|
||||
};
|
||||
let now = Instant::now();
|
||||
assert!(!engine.files_changed(now));
|
||||
assert!(engine.dirty.is_none());
|
||||
sender.send(()).unwrap();
|
||||
assert!(!engine.files_changed(now));
|
||||
assert_eq!(engine.dirty, Some(now + DEBOUNCE));
|
||||
let second = now + Duration::from_millis(100);
|
||||
sender.send(()).unwrap();
|
||||
assert!(!engine.files_changed(second));
|
||||
assert!(!engine.files_changed(now + DEBOUNCE));
|
||||
assert!(engine.files_changed(second + DEBOUNCE));
|
||||
assert!(engine.dirty.is_none());
|
||||
assert!(!engine.files_changed(second + Duration::from_secs(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_warning_survives_repeated_rejection_without_duplicate_publication() {
|
||||
let mut engine = Engine::default();
|
||||
|
||||
+66
-26
@@ -1,5 +1,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -195,27 +197,34 @@ fn values(document: &ferese_config::Document) -> Option<[bool; 7]> {
|
||||
}
|
||||
|
||||
fn load() -> Option<[bool; 7]> {
|
||||
let path = ferese_config::config_path()?;
|
||||
let source = match std::fs::read_to_string(path) {
|
||||
Ok(source) if source.len() <= 1024 * 1024 => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
_ => return None,
|
||||
};
|
||||
load_path(&ferese_config::config_path()?)
|
||||
}
|
||||
|
||||
fn load_path(path: &Path) -> Option<[bool; 7]> {
|
||||
let mut source = String::new();
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => {
|
||||
file.take((ferese_config::file::LIMIT + 1) as u64)
|
||||
.read_to_string(&mut source)
|
||||
.ok()?;
|
||||
if source.len() > ferese_config::file::LIMIT {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => return None,
|
||||
}
|
||||
values(&ferese_config::Document::parse(&source).ok()?)
|
||||
}
|
||||
|
||||
fn store(index: usize, value: bool) -> Result<(), String> {
|
||||
let _transaction = crate::backend::CONFIG_TRANSACTION
|
||||
.lock()
|
||||
.map_err(|_| "Configuration transaction failed")?;
|
||||
let path = ferese_config::config_path().ok_or("Missing config directory")?;
|
||||
let source = match std::fs::read_to_string(&path) {
|
||||
Ok(source) if source.len() <= 1024 * 1024 => source,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(error) => return Err(error.to_string()),
|
||||
_ => return Err("Configuration is too large".into()),
|
||||
};
|
||||
let mut document = ferese_config::Document::parse(&source).map_err(|error| error.to_string())?;
|
||||
store_path(&path, index, value)
|
||||
}
|
||||
|
||||
fn store_path(path: &Path, index: usize, value: bool) -> Result<(), String> {
|
||||
let file = ferese_config::file::ConfigFile::open(path)?;
|
||||
let mut document = ferese_config::Document::parse(file.source()).map_err(|error| error.to_string())?;
|
||||
if values(&document).is_none() {
|
||||
return Err("Invalid lockdown policy".into());
|
||||
}
|
||||
@@ -225,22 +234,53 @@ fn store(index: usize, value: bool) -> Result<(), String> {
|
||||
value.into(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let directory = path.parent().ok_or("Invalid config path")?;
|
||||
std::fs::create_dir_all(directory).map_err(|error| error.to_string())?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?;
|
||||
std::io::Write::write_all(&mut file, document.to_string().as_bytes()).map_err(|error| error.to_string())?;
|
||||
file.as_file().sync_all().map_err(|error| error.to_string())?;
|
||||
if std::fs::read_to_string(&path).unwrap_or_default() != source {
|
||||
return Err("Configuration changed; please try again".into());
|
||||
}
|
||||
file.persist(path).map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
file.replace(&document.to_string(), None, |_| Ok(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn policy_save_respects_other_config_writers_and_preserves_their_changes() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let settings = ferese_config::file::ConfigFile::open(&path).unwrap();
|
||||
assert!(store_path(&path, 4, true).unwrap_err().contains("another app"));
|
||||
assert!(!path.exists());
|
||||
settings.replace("theme \"light\"\n", None, |_| Ok(())).unwrap();
|
||||
|
||||
store_path(&path, 4, true).unwrap();
|
||||
let source = std::fs::read_to_string(&path).unwrap();
|
||||
let document = ferese_config::Document::parse(&source).unwrap();
|
||||
assert_eq!(document.get("theme").and_then(|value| value.as_str()), Some("light"));
|
||||
assert!(load_path(&path).unwrap()[4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_policy_files_and_saves_leave_config_untouched() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
for size in [ferese_config::file::LIMIT, ferese_config::file::LIMIT + 1] {
|
||||
let source = format!("//{}\n", "x".repeat(size - 3));
|
||||
std::fs::write(&path, &source).unwrap();
|
||||
assert_eq!(load_path(&path).is_some(), size == ferese_config::file::LIMIT);
|
||||
assert!(store_path(&path, 4, true).unwrap_err().contains("60 KiB"));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_policy_is_not_overwritten() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let source = "portals { lockdown { disable-camera \"yes\"; }; }";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
assert_eq!(store_path(&path, 4, true).unwrap_err(), "Invalid lockdown policy");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
|
||||
assert!(ferese_config::file::ConfigFile::open(&path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_policy_is_unrestricted_and_wrong_types_are_rejected() {
|
||||
let empty = ferese_config::Document::parse("").unwrap();
|
||||
|
||||
+222
-3
@@ -3,6 +3,8 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::RwLock;
|
||||
use zbus::Connection;
|
||||
use zbus::message::Header;
|
||||
@@ -10,7 +12,7 @@ use zbus::object_server::SignalEmitter;
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
||||
|
||||
use crate::backend::{Options, authorize};
|
||||
use crate::desktop::{Requests, consent, ipc, reply};
|
||||
use crate::desktop::{Requests, consent, reply};
|
||||
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
|
||||
@@ -127,8 +129,16 @@ impl Background {
|
||||
pub(crate) async fn watch(self, connection: Connection) {
|
||||
let emitter = SignalEmitter::new(&connection, PATH).unwrap();
|
||||
loop {
|
||||
if let Ok(windows) = ipc("get-windows", serde_json::json!({})).await {
|
||||
let next = app_states(&windows);
|
||||
let result = async {
|
||||
let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?;
|
||||
let stream = UnixStream::connect(path).await.map_err(|error| error.to_string())?;
|
||||
let mut events = BackgroundEvents::subscribe(stream).await?;
|
||||
loop {
|
||||
let next = match events.next().await {
|
||||
Ok(next) => next,
|
||||
Err(error) => break Err::<(), String>(error),
|
||||
};
|
||||
if let Some(next) = next {
|
||||
let mut previous = self.states.write().await;
|
||||
if *previous != next {
|
||||
*previous = next;
|
||||
@@ -136,11 +146,100 @@ impl Background {
|
||||
let _ = Self::running_applications_changed(&emitter).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
eprintln!("ferese portal: background event stream: {error}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tokio owns the socket so cancelling the watch task also closes an idle stream.
|
||||
struct BackgroundEvents {
|
||||
stream: UnixStream,
|
||||
windows: serde_json::Value,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
impl BackgroundEvents {
|
||||
async fn subscribe(mut stream: UnixStream) -> Result<Self, String> {
|
||||
let request = ferese_ipc::Request {
|
||||
version: ferese_ipc::VERSION,
|
||||
id: 1,
|
||||
kind: "command".into(),
|
||||
command: "event-stream".into(),
|
||||
args: serde_json::json!({"version": ferese_ipc::events::VERSION}),
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
ferese_ipc::write_frame(&mut bytes, &request).map_err(|error| error.to_string())?;
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
stream.write_all(&bytes).await.map_err(|error| error.to_string())?;
|
||||
let response: ferese_ipc::Response = read_event_frame(&mut stream).await?;
|
||||
if response.version != ferese_ipc::VERSION || response.id != request.id {
|
||||
return Err("Unexpected event subscription response".into());
|
||||
}
|
||||
if let Some(error) = response.error {
|
||||
return Err(error.message);
|
||||
}
|
||||
if response.result.as_ref().and_then(|value| value["version"].as_u64())
|
||||
!= Some(u64::from(ferese_ipc::events::VERSION))
|
||||
{
|
||||
return Err("Unsupported event stream version".into());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Event subscription timed out".to_owned())??;
|
||||
Ok(Self {
|
||||
stream,
|
||||
windows: serde_json::json!([]),
|
||||
changed: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Result<Option<HashMap<String, u32>>, String> {
|
||||
use ferese_ipc::events::{Change, Event};
|
||||
let event: Event = read_event_frame(&mut self.stream).await?;
|
||||
event.validate()?;
|
||||
match event.change {
|
||||
Change::Snapshot { desktop } => {
|
||||
self.windows = desktop.windows;
|
||||
self.changed = true;
|
||||
}
|
||||
Change::WindowsChanged { windows } => {
|
||||
self.windows = windows;
|
||||
self.changed = true;
|
||||
}
|
||||
Change::FocusChanged { focus } => {
|
||||
for window in self.windows.as_array_mut().into_iter().flatten() {
|
||||
window["focused"] =
|
||||
serde_json::json!(focus["window"].is_number() && window["id"] == focus["window"]);
|
||||
}
|
||||
self.changed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if event.last && std::mem::take(&mut self.changed) {
|
||||
Ok(Some(app_states(&self.windows)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_event_frame<T: serde::de::DeserializeOwned>(stream: &mut UnixStream) -> Result<T, String> {
|
||||
let size = stream.read_u32().await.map_err(|error| error.to_string())? as usize;
|
||||
if size > ferese_ipc::MAX_PAYLOAD_SIZE {
|
||||
return Err("Oversized IPC event frame".into());
|
||||
}
|
||||
let mut bytes = vec![0; size];
|
||||
stream.read_exact(&mut bytes).await.map_err(|error| error.to_string())?;
|
||||
serde_json::from_slice(&bytes).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[zbus::interface(name = "org.freedesktop.impl.portal.Background")]
|
||||
impl Background {
|
||||
async fn get_app_state(
|
||||
@@ -323,6 +422,126 @@ fn desktop_argument(argument: &str) -> Result<String, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn send_frame(peer: &mut UnixStream, value: &impl serde::Serialize) {
|
||||
let mut bytes = Vec::new();
|
||||
ferese_ipc::write_frame(&mut bytes, value).unwrap();
|
||||
peer.write_all(&bytes).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_events_apply_complete_generations_and_close_on_drop() {
|
||||
use ferese_ipc::events::{Change, Event};
|
||||
let (client, mut peer) = UnixStream::pair().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let request: ferese_ipc::Request = read_event_frame(&mut peer).await.unwrap();
|
||||
assert_eq!(request.command, "event-stream");
|
||||
assert_eq!(
|
||||
request.args,
|
||||
serde_json::json!({"version": ferese_ipc::events::VERSION})
|
||||
);
|
||||
send_frame(&mut peer, &ferese_ipc::Response::success(request.id, request.args)).await;
|
||||
peer
|
||||
});
|
||||
let mut events = BackgroundEvents::subscribe(client).await.unwrap();
|
||||
let mut peer = server.await.unwrap();
|
||||
send_frame(
|
||||
&mut peer,
|
||||
&Event {
|
||||
version: ferese_ipc::events::VERSION,
|
||||
generation: 0,
|
||||
last: true,
|
||||
change: Change::Snapshot {
|
||||
desktop: Box::new(ferese_ipc::events::Snapshot {
|
||||
outputs: serde_json::json!([]),
|
||||
workspaces: serde_json::json!([]),
|
||||
windows: serde_json::json!([{"id":1,"app_id":"one.desktop","focused":true}]),
|
||||
focus: serde_json::json!({"window":1}),
|
||||
config: Default::default(),
|
||||
theme: serde_json::Value::Null,
|
||||
lock: ferese_ipc::events::LockState {
|
||||
phase: "unlocked".into(),
|
||||
sleeping: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
events.next().await.unwrap().unwrap(),
|
||||
HashMap::from([("one".into(), 2)])
|
||||
);
|
||||
let windows = serde_json::json!([
|
||||
{"id":1,"app_id":"one.desktop","focused":true},
|
||||
{"id":2,"app_id":"two","focused":false}
|
||||
]);
|
||||
send_frame(
|
||||
&mut peer,
|
||||
&Event {
|
||||
version: ferese_ipc::events::VERSION,
|
||||
generation: 1,
|
||||
last: false,
|
||||
change: Change::WindowsChanged { windows },
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(events.next().await.unwrap().is_none());
|
||||
send_frame(
|
||||
&mut peer,
|
||||
&Event {
|
||||
version: ferese_ipc::events::VERSION,
|
||||
generation: 1,
|
||||
last: true,
|
||||
change: Change::FocusChanged {
|
||||
focus: serde_json::json!({"window":2}),
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
events.next().await.unwrap().unwrap(),
|
||||
HashMap::from([("one".into(), 1), ("two".into(), 2)])
|
||||
);
|
||||
// No command is sent while waiting for a change on an idle desktop.
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), peer.read_u32())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
send_frame(
|
||||
&mut peer,
|
||||
&Event {
|
||||
version: ferese_ipc::events::VERSION,
|
||||
generation: 2,
|
||||
last: true,
|
||||
change: Change::WindowsChanged {
|
||||
windows: serde_json::json!([]),
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(events.next().await.unwrap().unwrap().is_empty());
|
||||
drop(events);
|
||||
assert_eq!(
|
||||
peer.read_u8().await.unwrap_err().kind(),
|
||||
std::io::ErrorKind::UnexpectedEof
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_events_reject_oversized_frames_and_disconnects() {
|
||||
let (stream, mut peer) = UnixStream::pair().unwrap();
|
||||
let mut events = BackgroundEvents {
|
||||
stream,
|
||||
windows: serde_json::json!([]),
|
||||
changed: false,
|
||||
};
|
||||
peer.write_u32((ferese_ipc::MAX_PAYLOAD_SIZE + 1) as u32).await.unwrap();
|
||||
assert!(events.next().await.unwrap_err().contains("Oversized"));
|
||||
drop(peer);
|
||||
assert!(events.next().await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_state_groups_windows_and_keeps_inactive_workspaces_running() {
|
||||
let windows = serde_json::json!([
|
||||
|
||||
@@ -1,62 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic::iced::Subscription;
|
||||
use cosmic::iced::futures::SinkExt;
|
||||
use ferese_ipc::activities::Snapshot;
|
||||
use serde_json::json;
|
||||
|
||||
pub(crate) fn subscription() -> Subscription<Arc<Snapshot>> {
|
||||
Subscription::run(stream)
|
||||
}
|
||||
|
||||
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
|
||||
cosmic::iced::stream::channel(1, async |mut output| {
|
||||
loop {
|
||||
let connection = tokio::task::spawn_blocking(ferese_ipc::Connection::connect).await;
|
||||
let Ok(Ok(mut connection)) = connection else {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
};
|
||||
let Ok(cancellation) = connection.cancellation() else {
|
||||
return;
|
||||
};
|
||||
let (send, mut receive) = tokio::sync::watch::channel(None);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let Ok(value) = connection.call("activity-get", json!({})) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut snapshot) = serde_json::from_value::<Snapshot>(value) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let revision = snapshot.revision;
|
||||
if send.send(Some(Arc::new(snapshot))).is_err() {
|
||||
return;
|
||||
}
|
||||
match connection
|
||||
.wait("activity-watch", json!({"since":revision}))
|
||||
.and_then(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(next) => snapshot = next,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
while receive.changed().await.is_ok() {
|
||||
let snapshot = receive.borrow_and_update().clone();
|
||||
if let Some(snapshot) = snapshot
|
||||
&& output.send(snapshot).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
drop(cancellation);
|
||||
if output.send(Arc::new(Snapshot::default())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
crate::ipc_subscription::stream("activity-get", "activity-watch", |snapshot: &Snapshot| {
|
||||
snapshot.revision
|
||||
})
|
||||
}
|
||||
@@ -341,14 +341,14 @@ impl Default for SoftShadowConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load() -> Result<ShellConfig, String> {
|
||||
pub(crate) fn load(snapshot: &ferese_ipc::theme::Snapshot) -> Result<ShellConfig, String> {
|
||||
let Some(path) = config_path() else {
|
||||
return Ok(ShellConfig::default());
|
||||
};
|
||||
load_path(&path)
|
||||
load_path(&path, snapshot)
|
||||
}
|
||||
|
||||
fn load_path(path: &std::path::Path) -> Result<ShellConfig, String> {
|
||||
fn load_path(path: &std::path::Path, snapshot: &ferese_ipc::theme::Snapshot) -> Result<ShellConfig, String> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(ShellConfig::default()),
|
||||
@@ -364,13 +364,15 @@ fn load_path(path: &std::path::Path) -> Result<ShellConfig, String> {
|
||||
if source.len() > ferese_config::file::LIMIT {
|
||||
return Err(format!("{} exceeds 60 KiB", path.display()));
|
||||
}
|
||||
parse_source(&source).map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
|
||||
parse_source(&source, snapshot).map_err(|error| format!("invalid configuration in {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
pub(crate) fn parse_source(
|
||||
source: &str,
|
||||
snapshot: &ferese_ipc::theme::Snapshot,
|
||||
) -> Result<ShellConfig, ferese_config::Error> {
|
||||
let document = ferese_config::Document::parse(source)?;
|
||||
ferese_config::theme::validate_overrides(&document).map_err(ferese_config::Error::from)?;
|
||||
let snapshot = ferese_theme_client::service::current();
|
||||
let mut config = parse_document(
|
||||
&document.with_theme(&snapshot.presented),
|
||||
snapshot.presented.appearance,
|
||||
@@ -616,7 +618,10 @@ mod tests {
|
||||
format!("{section} {{ geometry {{ {key} 8; }}; }}")
|
||||
};
|
||||
assert!(
|
||||
parse_source(&format!("theme {{ {layer}; }}"))
|
||||
parse_source(
|
||||
&format!("theme {{ {layer}; }}"),
|
||||
&ferese_theme_client::service::fallback()
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains(&key.replace('-', "_"))
|
||||
@@ -669,7 +674,7 @@ mod tests {
|
||||
#[test]
|
||||
fn removed_desktop_widgets_allow_startup_and_reload() {
|
||||
let source = "desktop-widgets { clock { enabled #true; }; }";
|
||||
parse_source(source).unwrap();
|
||||
parse_source(source, &ferese_theme_client::service::fallback()).unwrap();
|
||||
parse_test_source(source).unwrap();
|
||||
}
|
||||
|
||||
@@ -802,28 +807,38 @@ theme {
|
||||
let source =
|
||||
"notifications { do-not-disturb #true; }\nstatus { bar-layout islands; battery-percentage #false; }";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let error = super::load_path(&path).unwrap_err();
|
||||
let error = super::load_path(&path, &ferese_theme_client::service::fallback()).unwrap_err();
|
||||
assert!(error.contains("bar_layout"), "{error}");
|
||||
assert!(error.contains(path.to_str().unwrap()), "{error}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
|
||||
std::fs::write(&path, "panel main {").unwrap();
|
||||
assert!(super::load_path(&path).is_err());
|
||||
assert!(super::load_path(&path, &ferese_theme_client::service::fallback()).is_err());
|
||||
let oversized = " ".repeat(ferese_config::file::LIMIT + 1);
|
||||
std::fs::write(&path, &oversized).unwrap();
|
||||
assert!(super::load_path(&path).unwrap_err().contains("60 KiB"));
|
||||
assert!(
|
||||
super::load_path(&path, &ferese_theme_client::service::fallback())
|
||||
.unwrap_err()
|
||||
.contains("60 KiB")
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), oversized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_distinguishes_absent_configuration_from_read_errors() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
assert!(super::load_path(&directory.path().join("missing.kdl")).is_ok());
|
||||
let error = super::load_path(directory.path()).unwrap_err();
|
||||
assert!(
|
||||
super::load_path(
|
||||
&directory.path().join("missing.kdl"),
|
||||
&ferese_theme_client::service::fallback()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
let error = super::load_path(directory.path(), &ferese_theme_client::service::fallback()).unwrap_err();
|
||||
assert!(error.contains("cannot read"), "{error}");
|
||||
let path = directory.path().join("config.kdl");
|
||||
std::os::unix::fs::symlink(directory.path().join("missing-target"), &path).unwrap();
|
||||
assert!(
|
||||
super::load_path(&path).is_err(),
|
||||
super::load_path(&path, &ferese_theme_client::service::fallback()).is_err(),
|
||||
"a broken config symlink is a read error"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic::iced::futures::{SinkExt, Stream};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
/// Follow a versioned snapshot, clearing stale content when its connection ends.
|
||||
pub(crate) fn stream<S>(get: &'static str, watch: &'static str, revision: fn(&S) -> u64) -> impl Stream<Item = Arc<S>>
|
||||
where
|
||||
S: DeserializeOwned + Default + Send + Sync + 'static,
|
||||
{
|
||||
cosmic::iced::stream::channel(1, async move |mut output| {
|
||||
loop {
|
||||
let connection = tokio::task::spawn_blocking(ferese_ipc::Connection::connect).await;
|
||||
let Ok(Ok(mut connection)) = connection else {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
};
|
||||
let Ok(cancellation) = connection.cancellation() else {
|
||||
return;
|
||||
};
|
||||
let (send, mut receive) = tokio::sync::watch::channel(None);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let Ok(value) = connection.call(get, json!({})) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut snapshot) = serde_json::from_value::<S>(value) else {
|
||||
return;
|
||||
};
|
||||
|
||||
loop {
|
||||
let since = revision(&snapshot);
|
||||
|
||||
if send.send(Some(Arc::new(snapshot))).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
match connection
|
||||
.wait(watch, json!({"since": since}))
|
||||
.and_then(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(next) => snapshot = next,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while receive.changed().await.is_ok() {
|
||||
let snapshot = receive.borrow_and_update().clone();
|
||||
if let Some(snapshot) = snapshot
|
||||
&& output.send(snapshot).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
drop(cancellation);
|
||||
|
||||
if output.send(Arc::new(S::default())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::iced::futures::StreamExt;
|
||||
use ferese_ipc::{Request, Response, read_frame, write_frame};
|
||||
use std::io::Read;
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
#[test]
|
||||
fn media_and_activities_reconnect_clear_stale_state_and_cancel_watches() {
|
||||
const CHILD: &str = "FERESE_SNAPSHOT_SUBSCRIPTION_TEST";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"ipc_subscription::tests::media_and_activities_reconnect_clear_stale_state_and_cancel_watches",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("XDG_RUNTIME_DIR", root.path())
|
||||
.env_remove("FERESE_SOCKET")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
check_stream::<ferese_ipc::media::Snapshot>("media-get", "media-watch", |snapshot| snapshot.revision);
|
||||
check_stream::<ferese_ipc::activities::Snapshot>("activity-get", "activity-watch", |snapshot| {
|
||||
snapshot.revision
|
||||
});
|
||||
}
|
||||
|
||||
fn check_stream<S>(get: &'static str, watch: &'static str, revision: fn(&S) -> u64)
|
||||
where
|
||||
S: DeserializeOwned + serde::Serialize + Default + Send + Sync + 'static,
|
||||
{
|
||||
let path = ferese_ipc::socket_path().unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let (consumed, consumed_receive) = std::sync::mpsc::channel();
|
||||
let (watch_send, watch_receive) = std::sync::mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
for initial in [1, 3] {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let mut connection = loop {
|
||||
match listener.accept() {
|
||||
Ok((connection, _)) => break connection,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(std::time::Instant::now() < deadline, "subscription did not reconnect");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(error) => panic!("accept: {error}"),
|
||||
}
|
||||
};
|
||||
connection.set_read_timeout(Some(Duration::from_secs(8))).unwrap();
|
||||
connection.set_write_timeout(Some(Duration::from_secs(8))).unwrap();
|
||||
let request: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(request.command, get);
|
||||
let mut snapshot = serde_json::to_value(S::default()).unwrap();
|
||||
snapshot["revision"] = json!(initial);
|
||||
write_frame(&mut connection, &Response::success(request.id, snapshot.clone())).unwrap();
|
||||
let request: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(request.command, watch);
|
||||
assert_eq!(request.args["since"], initial);
|
||||
if initial == 1 {
|
||||
// Wait for delivery so watch-channel coalescing cannot skip this snapshot.
|
||||
assert_eq!(consumed_receive.recv_timeout(Duration::from_secs(8)).unwrap(), 1);
|
||||
snapshot["revision"] = json!(2);
|
||||
write_frame(&mut connection, &Response::success(request.id, snapshot)).unwrap();
|
||||
let next: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(next.command, watch);
|
||||
assert_eq!(next.args["since"], 2);
|
||||
assert_ne!(request.id, next.id);
|
||||
assert_eq!(consumed_receive.recv_timeout(Duration::from_secs(8)).unwrap(), 2);
|
||||
// Dropping this connection must clear the old snapshot before retrying.
|
||||
} else {
|
||||
watch_send.send(()).unwrap();
|
||||
assert_eq!(
|
||||
connection.read(&mut [0]).unwrap(),
|
||||
0,
|
||||
"dropped subscription kept its socket alive"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let mut subscription = Box::pin(stream::<S>(get, watch, revision));
|
||||
for expected in [1, 2, 0, 3] {
|
||||
let snapshot = tokio::time::timeout(Duration::from_secs(8), subscription.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(revision(&snapshot), expected);
|
||||
if expected == 1 || expected == 2 {
|
||||
consumed.send(expected).unwrap();
|
||||
}
|
||||
}
|
||||
tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(2)).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
drop(subscription);
|
||||
});
|
||||
server.join().unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod compositor_ipc;
|
||||
mod config;
|
||||
mod control;
|
||||
mod display_mode;
|
||||
mod ipc_subscription;
|
||||
mod keybinding_guide;
|
||||
mod media;
|
||||
mod motion;
|
||||
@@ -101,7 +102,8 @@ impl From<panel::PanelGeometry> for BarMetrics {
|
||||
}
|
||||
|
||||
fn main() -> cosmic::iced::Result {
|
||||
let (mut config, startup_error) = match config::load() {
|
||||
let theme_snapshot = ferese_theme_client::service::current();
|
||||
let (mut config, startup_error) = match config::load(&theme_snapshot) {
|
||||
Ok(config) => (config, None),
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: {error}; starting with defaults");
|
||||
@@ -140,7 +142,7 @@ fn main() -> cosmic::iced::Result {
|
||||
.default_font(shell_font)
|
||||
.theme(config.theme.palette().native_theme());
|
||||
|
||||
cosmic::app::run::<FereseShell>(settings, (config, wallpaper, startup_error))
|
||||
cosmic::app::run::<FereseShell>(settings, (config, wallpaper, startup_error, theme_snapshot))
|
||||
}
|
||||
|
||||
type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver<Result<image::Handle, String>>;
|
||||
@@ -148,6 +150,7 @@ type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver<Result<im
|
||||
struct FereseShell {
|
||||
core: Core,
|
||||
config: ShellConfig,
|
||||
theme_snapshot: ferese_ipc::theme::Snapshot,
|
||||
render: rendering::Rendering,
|
||||
wallpaper: Option<image::Handle>,
|
||||
control: Option<ShellControl>,
|
||||
@@ -285,7 +288,12 @@ enum Message {
|
||||
|
||||
impl cosmic::Application for FereseShell {
|
||||
type Executor = cosmic::executor::Default;
|
||||
type Flags = (ShellConfig, Option<WallpaperLoad>, Option<String>);
|
||||
type Flags = (
|
||||
ShellConfig,
|
||||
Option<WallpaperLoad>,
|
||||
Option<String>,
|
||||
ferese_ipc::theme::Snapshot,
|
||||
);
|
||||
type Message = Message;
|
||||
|
||||
const APP_ID: &'static str = APP_ID;
|
||||
@@ -298,7 +306,10 @@ impl cosmic::Application for FereseShell {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
fn init(core: Core, (config, wallpaper, startup_error): Self::Flags) -> (Self, Task<Self::Message>) {
|
||||
fn init(
|
||||
core: Core,
|
||||
(config, wallpaper, startup_error, theme_snapshot): Self::Flags,
|
||||
) -> (Self, Task<Self::Message>) {
|
||||
let control = ShellControl::connect()
|
||||
.map_err(|error| eprintln!("ferese-shell: shell control unavailable: {error}"))
|
||||
.ok();
|
||||
@@ -309,6 +320,7 @@ impl cosmic::Application for FereseShell {
|
||||
notification_center.activity_bridge = config.activities.sources.notification_bridge;
|
||||
let app = Self {
|
||||
core,
|
||||
theme_snapshot,
|
||||
notifications: notification_center,
|
||||
notification_surface: None,
|
||||
status_service: status::Service::start(config.status.settings_command.clone()),
|
||||
@@ -511,8 +523,9 @@ impl cosmic::Application for FereseShell {
|
||||
config.theme_mode = snapshot.mode;
|
||||
if snapshot.error != self.theme_error {
|
||||
self.pending_config_error = snapshot.error.clone();
|
||||
self.theme_error = snapshot.error;
|
||||
self.theme_error = snapshot.error.clone();
|
||||
}
|
||||
self.theme_snapshot = *snapshot;
|
||||
self.apply_config(config)
|
||||
}
|
||||
Message::ThemeMode(mode) => cosmic::task::future(async move {
|
||||
@@ -959,7 +972,7 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
fn reload_config(&mut self, source: String) -> Task<Message> {
|
||||
let config = match config::parse_source(&source) {
|
||||
let config = match config::parse_source(&source, &self.theme_snapshot) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
|
||||
@@ -1137,6 +1150,7 @@ mod tests {
|
||||
let output = wl_output::WlOutput::inert(connection.backend().downgrade());
|
||||
FereseShell {
|
||||
core: Core::default(),
|
||||
theme_snapshot: ferese_theme_client::service::fallback(),
|
||||
notifications: notifications::Center::new(config.notifications.clone(), config.animations),
|
||||
notification_surface: None,
|
||||
status_service: status::Service::start(None),
|
||||
@@ -1196,6 +1210,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_preserves_the_last_presented_theme_without_a_control_connection() {
|
||||
use cosmic::Application;
|
||||
|
||||
let mut shell = shell_with_measured_panel();
|
||||
let mut snapshot = ferese_theme_client::service::fallback();
|
||||
snapshot.mode = ferese_config::theme::Mode::Light;
|
||||
snapshot.presented.appearance = ferese_config::theme::Appearance::Light;
|
||||
snapshot.presented.tokens.geometry.shell_radius = 23.;
|
||||
snapshot.presented.tokens.typography.font_family = "Reload test font".into();
|
||||
drop(shell.update(Message::ThemeChanged(Box::new(snapshot.clone()))));
|
||||
|
||||
drop(shell.reload_config("status { low-battery-threshold 35; }".into()));
|
||||
|
||||
assert!(shell.control.is_none());
|
||||
assert_eq!(shell.config.status.low_battery_threshold, 35);
|
||||
assert_eq!(shell.config.theme_mode, snapshot.mode);
|
||||
assert_eq!(shell.config.theme.appearance, snapshot.presented.appearance);
|
||||
assert_eq!(shell.config.theme.material_radius, 23.);
|
||||
assert_eq!(shell.config.font_family.as_deref(), Some("Reload test font"));
|
||||
assert_eq!(shell.theme_snapshot, snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosmetic_reloads_preserve_measured_margins_and_the_open_popover() {
|
||||
let mut shell = shell_with_measured_panel();
|
||||
@@ -1230,7 +1267,7 @@ mod tests {
|
||||
.unwrap();
|
||||
document.set(&format!("panels.0.{field}"), value).unwrap();
|
||||
let source = document.to_string();
|
||||
let mut config = config::parse_source(&source).unwrap();
|
||||
let mut config = config::parse_source(&source, &shell.theme_snapshot).unwrap();
|
||||
config.theme = shell.config.theme;
|
||||
drop(shell.apply_config(config));
|
||||
assert_eq!(shell.outputs[0].panel_resolution, resolution, "{edit}");
|
||||
|
||||
@@ -1,147 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic::iced::Subscription;
|
||||
use cosmic::iced::futures::SinkExt;
|
||||
use ferese_ipc::media::Snapshot;
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn subscription() -> Subscription<Arc<Snapshot>> {
|
||||
Subscription::run(stream)
|
||||
}
|
||||
|
||||
fn stream() -> impl cosmic::iced::futures::Stream<Item = Arc<Snapshot>> {
|
||||
cosmic::iced::stream::channel(1, async |mut output| {
|
||||
loop {
|
||||
let connection = tokio::task::spawn_blocking(ferese_ipc::Connection::connect).await;
|
||||
let Ok(Ok(mut connection)) = connection else {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
};
|
||||
let Ok(cancellation) = connection.cancellation() else {
|
||||
return;
|
||||
};
|
||||
let (send, mut receive) = tokio::sync::watch::channel(None);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let Ok(value) = connection.call("media-get", json!({})) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut snapshot) = serde_json::from_value::<Snapshot>(value) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
let revision = snapshot.revision;
|
||||
if send.send(Some(Arc::new(snapshot))).is_err() {
|
||||
return;
|
||||
}
|
||||
match connection
|
||||
.wait("media-watch", json!({"since":revision}))
|
||||
.and_then(|value| serde_json::from_value(value).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(next) => snapshot = next,
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
while receive.changed().await.is_ok() {
|
||||
let snapshot = receive.borrow_and_update().clone();
|
||||
if let Some(snapshot) = snapshot
|
||||
&& output.send(snapshot).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
drop(cancellation);
|
||||
if output.send(Arc::new(Snapshot::default())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::iced::futures::StreamExt;
|
||||
use ferese_ipc::{Request, Response, read_frame, write_frame};
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
#[test]
|
||||
fn reconnect_clears_ghost_media_and_drop_cancels_a_watch() {
|
||||
const CHILD: &str = "FERESE_MEDIA_SUBSCRIPTION_TEST";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"media::subscription::tests::reconnect_clears_ghost_media_and_drop_cancels_a_watch",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD, "1")
|
||||
.env("XDG_RUNTIME_DIR", root.path())
|
||||
.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 path = ferese_ipc::socket_path().unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let listener = UnixListener::bind(path).unwrap();
|
||||
let (watch_send, watch_receive) = std::sync::mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
for revision in [1, 2] {
|
||||
let (mut connection, _) = listener.accept().unwrap();
|
||||
connection.set_read_timeout(Some(Duration::from_secs(8))).unwrap();
|
||||
let get: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(get.command, "media-get");
|
||||
let snapshot = Snapshot {
|
||||
revision,
|
||||
..Default::default()
|
||||
};
|
||||
write_frame(
|
||||
&mut connection,
|
||||
&Response::success(get.id, serde_json::to_value(snapshot).unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let watch: Request = read_frame(&mut connection).unwrap();
|
||||
assert_eq!(watch.command, "media-watch");
|
||||
assert_eq!(watch.args["since"], revision);
|
||||
assert_ne!(get.id, watch.id);
|
||||
if revision == 2 {
|
||||
watch_send.send(()).unwrap();
|
||||
use std::io::Read;
|
||||
assert_eq!(connection.read(&mut [0]).unwrap(), 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let mut subscription = Box::pin(stream());
|
||||
for revision in [1, 0, 2] {
|
||||
let snapshot = tokio::time::timeout(Duration::from_secs(8), subscription.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.revision, revision);
|
||||
}
|
||||
|
||||
tokio::task::spawn_blocking(move || watch_receive.recv_timeout(Duration::from_secs(2)).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
drop(subscription);
|
||||
});
|
||||
server.join().unwrap();
|
||||
}
|
||||
crate::ipc_subscription::stream("media-get", "media-watch", |snapshot: &Snapshot| snapshot.revision)
|
||||
}
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
// Progressive enhancement for the handbook. Navigation and content work without JS.
|
||||
const handbookNavigation = document.querySelector('.handbook-navigation');
|
||||
if (handbookNavigation) {
|
||||
const compactLayout = matchMedia('(max-width: 900px)');
|
||||
const updateNavigation = () => { handbookNavigation.open = !compactLayout.matches; };
|
||||
updateNavigation();
|
||||
compactLayout.addEventListener('change', updateNavigation);
|
||||
handbookNavigation.addEventListener('click', (event) => {
|
||||
if (compactLayout.matches && event.target.closest('nav a')) handbookNavigation.open = false;
|
||||
});
|
||||
}
|
||||
const search = document.querySelector("#docs-search");
|
||||
const results = document.querySelector("#search-results");
|
||||
const status = document.querySelector("#search-status");
|
||||
let indexPromise;
|
||||
if (search) {
|
||||
search.closest(".docs-search").hidden = false;
|
||||
const loadIndex = () =>
|
||||
(indexPromise ||= fetch("search-index.json")
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Search index unavailable");
|
||||
return response.json();
|
||||
})
|
||||
.catch((error) => {
|
||||
indexPromise = null;
|
||||
throw error;
|
||||
}));
|
||||
search.addEventListener("focus", () => {
|
||||
loadIndex().catch(() => {});
|
||||
});
|
||||
search.addEventListener("input", async () => {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
if (!query) {
|
||||
results.hidden = true;
|
||||
results.replaceChildren();
|
||||
status.textContent = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const index = await loadIndex();
|
||||
if (search.value.trim().toLocaleLowerCase() !== query) return;
|
||||
const words = query.split(/\s+/);
|
||||
const matches = index
|
||||
.filter((item) =>
|
||||
words.every((word) =>
|
||||
`${item.page} ${item.heading} ${item.text}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(word),
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.heading.toLocaleLowerCase().includes(query)) -
|
||||
Number(a.heading.toLocaleLowerCase().includes(query)),
|
||||
)
|
||||
.slice(0, 10);
|
||||
results.replaceChildren();
|
||||
for (const item of matches) {
|
||||
const link = document.createElement("a");
|
||||
link.href = item.url;
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = `${item.page} / ${item.heading}`;
|
||||
const snippet = document.createElement("span");
|
||||
const position = Math.max(
|
||||
0,
|
||||
item.text.toLocaleLowerCase().indexOf(words[0]) - 45,
|
||||
);
|
||||
snippet.textContent = `${position ? "…" : ""}${item.text.slice(position, position + 165)}${item.text.length > position + 165 ? "…" : ""}`;
|
||||
link.append(title, snippet);
|
||||
results.append(link);
|
||||
}
|
||||
if (!matches.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.textContent =
|
||||
"No matching sections. Try “wallpaper”, “shortcuts”, or “notifications”.";
|
||||
results.append(empty);
|
||||
}
|
||||
results.hidden = false;
|
||||
status.textContent = `${matches.length} matching sections.`;
|
||||
} catch {
|
||||
if (search.value.trim().toLocaleLowerCase() !== query) return;
|
||||
results.hidden = true;
|
||||
status.textContent = "Search is unavailable. Browse the guides below.";
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
results.hidden = true;
|
||||
search.focus();
|
||||
}
|
||||
if (
|
||||
event.key === "/" &&
|
||||
!["INPUT", "TEXTAREA"].includes(document.activeElement.tagName) &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
search.focus();
|
||||
}
|
||||
if (
|
||||
event.key === "ArrowDown" &&
|
||||
document.activeElement === search &&
|
||||
!results.hidden
|
||||
) {
|
||||
const first = results.querySelector("a");
|
||||
if (first) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest(".docs-search")) results.hidden = true;
|
||||
});
|
||||
}
|
||||
if (navigator.clipboard?.writeText) {
|
||||
for (const block of document.querySelectorAll(".prose pre")) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "code-copy";
|
||||
button.textContent = "Copy";
|
||||
button.setAttribute("aria-label", "Copy code example");
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
block.querySelector("code").textContent,
|
||||
);
|
||||
button.textContent = "Copied";
|
||||
if (status) status.textContent = "Code example copied.";
|
||||
} catch {
|
||||
button.textContent = "Select to copy";
|
||||
if (status)
|
||||
status.textContent =
|
||||
"Copy failed. Select the code to copy it manually.";
|
||||
}
|
||||
});
|
||||
block.append(button);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# Ferese handbook
|
||||
|
||||
Ferese combines a window manager and desktop shell with one configuration.
|
||||
This handbook covers installation, settings and desktop controls.
|
||||
|
||||
[Browse screenshots](screenshots.md) to see the themes, layouts and desktop controls in
|
||||
use.
|
||||
|
||||
## Get started
|
||||
|
||||
1. [Install Ferese](installation.md), or try a [nested preview](installation.md#preview-and-logs).
|
||||
2. Choose Light, Dark, or Auto in **Control Center**. Open **Settings** for theme files and presets.
|
||||
3. Use the [configuration reference](configuration.md) for shortcuts, gestures, window rules, and displays.
|
||||
|
||||
## Default shortcuts
|
||||
|
||||
Use `Super`, usually the Windows key, for the shortcuts below, and make them your own in
|
||||
**Settings → Keyboard & mouse → Shortcuts**.
|
||||
|
||||
| Control | Action |
|
||||
| --- | --- |
|
||||
| Super + Enter | Open a terminal |
|
||||
| Super + H / J / K / L | Focus left / down / up / right |
|
||||
| Super + F | Maximize the window |
|
||||
| Super + Shift + F | Toggle fullscreen |
|
||||
| Super + Tab | Open overview |
|
||||
| Super + M | Switch scrolling / tree layout |
|
||||
| Print Screen | Capture the active monitor and open Satty |
|
||||
| Super + Shift + S | Select a screenshot area |
|
||||
| Super + Shift + E | Ask to log out |
|
||||
| Super + left-button drag | Move a floating window |
|
||||
| Super + right-button drag | Resize a floating window |
|
||||
| Three-finger swipe up / down | Next / previous workspace |
|
||||
| Three-finger swipe left / right | Focus the window to the right / left |
|
||||
| Login shortcut guide | Appears at login; disable in Settings → Keyboard & mouse → Shortcuts |
|
||||
|
||||
The [shortcuts and mouse actions guide](shortcuts.md) covers the rest, including how to
|
||||
group windows, resize them and move between workspaces.
|
||||
|
||||
## Desktop guides
|
||||
|
||||
- [Panel configuration](panel-configuration.md): choose a style, arrange controls, and configure Morph.
|
||||
- [Lock screen](locking.md): appearance, automatic locking, and display sleep.
|
||||
- [Screen sharing and recording](screen-sharing.md): share an app or display, or save a video.
|
||||
- [Desktop portals](portals.md): integration with applications.
|
||||
- [Animation model](animation-model.md): spring motion, geometry ownership, and resize presentation.
|
||||
|
||||
Configure the desktop in Settings or edit `~/.config/ferese/config.kdl` directly;
|
||||
changes apply live, and an invalid edit leaves the last working configuration in
|
||||
place.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you need to end a session, `feresectl request-logout` asks for confirmation and
|
||||
`feresectl exit` exits immediately, so save your work first. The [logs and recovery
|
||||
guide](installation.md#logout-and-recovery) helps when something stops responding.
|
||||
|
||||
To [report a problem](https://github.com/ferese-wm/ferese/issues), include the steps to
|
||||
reproduce it, relevant logs and whether you were using a nested preview or a direct
|
||||
session. See [Development](development.md) to contribute.
|
||||
@@ -1,128 +0,0 @@
|
||||
.docs-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 195px minmax(0, 1fr) 165px;
|
||||
gap: 38px;
|
||||
align-items: start;
|
||||
padding-block: 48px 90px;
|
||||
}
|
||||
.docs-sidebar, .docs-toc {
|
||||
position: sticky;
|
||||
top: 110px;
|
||||
max-height: calc(100dvh - 145px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line) transparent;
|
||||
}
|
||||
.docs-sidebar { z-index: 2; }
|
||||
.docs-sidebar summary { list-style: none; color: var(--muted); font-size: 12px; font-weight: 550; margin: 0 0 14px; }
|
||||
.docs-sidebar summary::-webkit-details-marker { display: none; }
|
||||
.docs-sidebar summary .icon { display: none; }
|
||||
.docs-sidebar nav { display: grid; gap: 4px; }
|
||||
.docs-sidebar nav a {
|
||||
display: block;
|
||||
padding: 9px 12px;
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.docs-sidebar nav a:hover { background: var(--soft); color: var(--ink); }
|
||||
.docs-sidebar nav a[aria-current=page] { background: #3d7be618; color: var(--accent); font-weight: 550; }
|
||||
.sidebar-links { display: grid; gap: 14px; margin-top: 25px; padding: 21px 12px 0; border-top: 1px solid var(--line); }
|
||||
.sidebar-links a { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; }
|
||||
.sidebar-links a:hover { color: var(--ink); }
|
||||
.sidebar-links .icon { width: 12px; height: 12px; }
|
||||
.docs-article { min-width: 0; }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 9px; margin-bottom: 20px; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
.breadcrumb a:hover { color: var(--accent); }
|
||||
.prose { min-width: 0; font-size: 15px; line-height: 1.95; overflow-wrap: anywhere; }
|
||||
.prose h1 { margin: 0 0 24px; color: var(--ink); font-size: 44px; font-weight: 560; line-height: 1.15; letter-spacing: -1.7px; text-wrap: balance; }
|
||||
.prose > p { margin: 18px 0; color: var(--muted); }
|
||||
.prose > p:first-of-type { font-size: 17px; line-height: 1.85; }
|
||||
.prose h2 { margin: 48px 0 18px; padding-top: 27px; border-top: 1px solid var(--line); font-size: 27px; font-weight: 550; letter-spacing: -.8px; line-height: 1.25; text-wrap: balance; }
|
||||
.prose h3 { margin: 31px 0 12px; font-size: 19px; font-weight: 550; letter-spacing: -.3px; line-height: 1.4; }
|
||||
.prose h4 { margin: 25px 0 10px; font-size: 16px; font-weight: 550; }
|
||||
.prose a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.prose a:hover { color: var(--ink); }
|
||||
.prose strong { color: var(--ink); font-weight: 550; }
|
||||
.prose a:has(img) { display: block; max-width: 100%; line-height: 0; border: 1px solid var(--line); border-radius: 15px; overflow: hidden; }
|
||||
.prose img { display: block; width: 100%; height: auto; }
|
||||
.prose code { font: .85em var(--mono); color: #dbe7fa; padding: 2px 5px; background: var(--raised); border-radius: 4px; overflow-wrap: anywhere; }
|
||||
.prose pre { position: relative; margin: 25px 0; padding: 43px 18px 18px; max-width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; background: var(--soft); color: #dbe7fa; font: 12px/1.85 var(--mono); }
|
||||
.prose pre code { padding: 0; background: none; white-space: pre; overflow-wrap: normal; font: inherit; }
|
||||
.code-copy { position: absolute; top: 9px; right: 10px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--raised); color: var(--muted); font-size: 10px; }
|
||||
.code-copy:hover { color: var(--ink); border-color: #ffffff25; }
|
||||
.prose ul, .prose ol { padding-left: 22px; color: var(--muted); }
|
||||
.prose li { margin-bottom: 9px; }
|
||||
.prose li::marker { color: var(--muted); }
|
||||
.prose li > p { margin-block: 8px; }
|
||||
.prose blockquote { margin: 26px 0; padding: 4px 20px; border-left: 2px solid var(--accent); background: var(--soft); border-radius: 0 10px 10px 0; color: var(--muted); }
|
||||
.prose blockquote p { margin: 12px 0; }
|
||||
.prose hr { border: 0; border-top: 1px solid var(--line); margin-block: 35px; }
|
||||
.table-scroll { max-width: 100%; margin: 25px 0; overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.prose table { width: 100%; border-collapse: collapse; color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||
.prose th { text-align: left; color: var(--ink); background: var(--soft); font-weight: 550; white-space: nowrap; }
|
||||
.prose td, .prose th { padding: 13px 15px; border-bottom: 1px solid var(--line); min-width: 110px; vertical-align: top; }
|
||||
.prose td:first-child { min-width: 145px; }
|
||||
.prose tr:last-child td { border-bottom: 0; }
|
||||
.docs-toc > p { margin: 0 0 15px; color: var(--muted); font-size: 11px; font-weight: 550; }
|
||||
.docs-toc nav { display: grid; gap: 12px; padding-left: 14px; border-left: 1px solid var(--line); }
|
||||
.docs-toc a { color: var(--muted); font-size: 11px; line-height: 1.65; }
|
||||
.docs-toc a:hover { color: var(--accent); }
|
||||
.mobile-contents { display: none; }
|
||||
.edit-link { display: inline-flex; align-items: center; gap: 6px; margin-top: 28px; color: var(--muted); font-size: 11px; }
|
||||
.edit-link:hover { color: var(--accent); }
|
||||
.edit-link .icon { width: 12px; height: 12px; }
|
||||
.docs-pager { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 40px; }
|
||||
.docs-pager a { min-width: 0; padding: 18px; border: 1px solid var(--line); border-radius: 12px; font-size: 13px; line-height: 1.6; transition: background .15s, border-color .15s; }
|
||||
.docs-pager a:hover { background: var(--soft); border-color: #ffffff25; }
|
||||
.docs-pager .pager-next { grid-column: 2; text-align: right; }
|
||||
.docs-pager small { display: block; margin-bottom: 5px; color: var(--muted); font-size: 10px; }
|
||||
.docs-search { position: relative; margin-bottom: 26px; }
|
||||
.docs-search input { display: block; width: 100%; padding: 11px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--soft); color: var(--ink); font-size: 12px; }
|
||||
.docs-search input::placeholder { color: var(--muted); opacity: 1; }
|
||||
.search-results { position: absolute; z-index: 10; top: calc(100% + 8px); left: 0; width: min(560px, calc(100vw - 80px)); max-height: 60dvh; overflow: auto; border: 1px solid #ffffff25; border-radius: 12px; background: var(--raised); box-shadow: 0 20px 50px #0006; }
|
||||
.search-results a { display: block; padding: 16px; border-bottom: 1px solid var(--line); }
|
||||
.search-results a:last-child { border-bottom: 0; }
|
||||
.search-results a:hover { background: #ffffff06; }
|
||||
.search-results strong { display: block; color: var(--accent); font-size: 13px; font-weight: 550; }
|
||||
.search-results span { display: block; margin-top: 5px; color: var(--muted); font-size: 11px; line-height: 1.7; }
|
||||
.search-results > p { margin: 0; padding: 16px; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||
.docs-sidebar:has(.search-results:not([hidden])) { overflow: visible; }
|
||||
@media (max-width: 1100px) {
|
||||
.docs-layout { grid-template-columns: 185px minmax(0, 1fr); gap: 36px; }
|
||||
.docs-toc { display: none; }
|
||||
.mobile-contents { display: block; margin-bottom: 25px; border-bottom: 1px solid var(--line); padding-bottom: 14px; }
|
||||
.mobile-contents summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
||||
.mobile-contents nav { display: grid; gap: 11px; margin-top: 15px; }
|
||||
.mobile-contents a { color: var(--muted); font-size: 12px; line-height: 1.5; }
|
||||
.mobile-contents a:hover { color: var(--accent); }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.docs-layout { display: block; padding-block: 30px 70px; }
|
||||
.docs-sidebar { position: static; max-height: none; overflow: visible; margin-bottom: 36px; padding: 16px; border: 1px solid var(--line); border-radius: 13px; background: var(--soft); }
|
||||
.docs-search { margin-bottom: 15px; }
|
||||
.docs-search input { background: var(--page); }
|
||||
.docs-sidebar summary { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0; cursor: pointer; font-size: 13px; color: var(--ink); }
|
||||
.docs-sidebar summary .icon { display: block; width: 14px; height: 14px; transform: rotate(90deg); }
|
||||
.docs-sidebar details[open] summary .icon { transform: rotate(-90deg); }
|
||||
.docs-sidebar nav { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 15px; }
|
||||
.docs-sidebar nav a { font-size: 12px; }
|
||||
.sidebar-links { display: none; }
|
||||
.search-results { width: 100%; }
|
||||
.prose h1 { font-size: 40px; letter-spacing: -1.3px; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.docs-layout { padding-block: 24px 60px; }
|
||||
.docs-sidebar { margin-bottom: 30px; }
|
||||
.breadcrumb { flex-wrap: wrap; gap: 7px; margin-bottom: 16px; }
|
||||
.prose { font-size: 15px; line-height: 1.9; }
|
||||
.prose > p:first-of-type { font-size: 16px; }
|
||||
.prose h1 { font-size: 35px; letter-spacing: -1px; }
|
||||
.prose h2 { margin-top: 37px; padding-top: 23px; font-size: 25px; }
|
||||
.prose h3 { font-size: 18px; }
|
||||
.prose pre { padding-inline: 14px; font-size: 11px; }
|
||||
.docs-pager { gap: 10px; }
|
||||
.docs-pager a { padding: 14px 12px; font-size: 12px; }
|
||||
}
|
||||
Reference in new issue
Block a user