Fix config and IPC lifecycle issues and remove obsolete code

This commit is contained in:
Samuel Onoja committed 2026-10-13 03:36:25 +01:00
1 parent 8007f7a107
commit 5d9673aad3
14 files changed
+723 -753

No files matched your search

+10 -154
View File
@@ -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());
}
}
+13 -2
View File
@@ -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;
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+227 -8
View File
@@ -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,20 +129,117 @@ 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 mut previous = self.states.write().await;
if *previous != next {
*previous = next;
drop(previous);
let _ = Self::running_applications_changed(&emitter).await;
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;
drop(previous);
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
})
}
+32 -17
View File
@@ -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,10 +618,13 @@ mod tests {
format!("{section} {{ geometry {{ {key} 8; }}; }}")
};
assert!(
parse_source(&format!("theme {{ {layer}; }}"))
.unwrap_err()
.to_string()
.contains(&key.replace('-', "_"))
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"
);
}
+186
View File
@@ -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();
}
}
+44 -7
View File
@@ -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 -136
View File
@@ -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
View File
@@ -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);
}
}
-60
View File
@@ -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.
-128
View File
@@ -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; }
}