diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index af2d9fc..1651823 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -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 { - 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, - observed: Option, -} - -#[cfg(test)] -impl ConfigMonitor { - #[cfg(test)] - pub(crate) fn new(path: PathBuf) -> notify::Result { - Self::with_wakeup(path, None) - } - - pub(crate) fn with_wakeup( - path: PathBuf, - wakeup: Option, - ) -> notify::Result { - 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| { - 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 { - self.dirty.map(|since| since + Duration::from_millis(120)) - } - - pub(crate) fn poll(&mut self, now: Instant) -> Option> { - 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()); } } diff --git a/compositor/src/resume.rs b/compositor/src/resume.rs index 5f6b0a9..bb6d7cc 100644 --- a/compositor/src/resume.rs +++ b/compositor/src/resume.rs @@ -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; diff --git a/compositor/src/resume/tests.rs b/compositor/src/resume/tests.rs index 503a819..91b4555 100644 --- a/compositor/src/resume/tests.rs +++ b/compositor/src/resume/tests.rs @@ -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(); +} diff --git a/compositor/src/theme.rs b/compositor/src/theme.rs index 617c318..7edc71b 100644 --- a/compositor/src/theme.rs +++ b/compositor/src/theme.rs @@ -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| { - 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>>, + sender: mpsc::SyncSender<()>, + wakeup: calloop::LoopSignal, +) -> notify::Result { + notify::recommended_watcher(move |event: notify::Result| { + 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(); diff --git a/portal/src/lockdown.rs b/portal/src/lockdown.rs index eda191b..5703766 100644 --- a/portal/src/lockdown.rs +++ b/portal/src/lockdown.rs @@ -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(); diff --git a/portal/src/permissions.rs b/portal/src/permissions.rs index d2609b3..826fbf0 100644 --- a/portal/src/permissions.rs +++ b/portal/src/permissions.rs @@ -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 { + 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>, 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(stream: &mut UnixStream) -> Result { + 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 { 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!([ diff --git a/shell/ferese-shell/src/activities/subscription.rs b/shell/ferese-shell/src/activities/subscription.rs index 79f9f09..11b3ff7 100644 --- a/shell/ferese-shell/src/activities/subscription.rs +++ b/shell/ferese-shell/src/activities/subscription.rs @@ -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> { Subscription::run(stream) } fn stream() -> impl cosmic::iced::futures::Stream> { - 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::(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 }) } diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index f21fda8..fd51587 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -341,14 +341,14 @@ impl Default for SoftShadowConfig { } } -pub(crate) fn load() -> Result { +pub(crate) fn load(snapshot: &ferese_ipc::theme::Snapshot) -> Result { 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 { +fn load_path(path: &std::path::Path, snapshot: &ferese_ipc::theme::Snapshot) -> Result { 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 { 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 { +pub(crate) fn parse_source( + source: &str, + snapshot: &ferese_ipc::theme::Snapshot, +) -> Result { 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" ); } diff --git a/shell/ferese-shell/src/ipc_subscription.rs b/shell/ferese-shell/src/ipc_subscription.rs new file mode 100644 index 0000000..e1f9e03 --- /dev/null +++ b/shell/ferese-shell/src/ipc_subscription.rs @@ -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(get: &'static str, watch: &'static str, revision: fn(&S) -> u64) -> impl Stream> +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::(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::("media-get", "media-watch", |snapshot| snapshot.revision); + check_stream::("activity-get", "activity-watch", |snapshot| { + snapshot.revision + }); + } + + fn check_stream(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::(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(); + } +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 953ead8..7c1126c 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -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 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::(settings, (config, wallpaper, startup_error)) + cosmic::app::run::(settings, (config, wallpaper, startup_error, theme_snapshot)) } type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver>; @@ -148,6 +150,7 @@ type WallpaperLoad = cosmic::iced::futures::channel::oneshot::Receiver, control: Option, @@ -285,7 +288,12 @@ enum Message { impl cosmic::Application for FereseShell { type Executor = cosmic::executor::Default; - type Flags = (ShellConfig, Option, Option); + type Flags = ( + ShellConfig, + Option, + Option, + 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) { + fn init( + core: Core, + (config, wallpaper, startup_error, theme_snapshot): Self::Flags, + ) -> (Self, Task) { 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 { - 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}"); diff --git a/shell/ferese-shell/src/media/subscription.rs b/shell/ferese-shell/src/media/subscription.rs index 8e600e4..661234c 100644 --- a/shell/ferese-shell/src/media/subscription.rs +++ b/shell/ferese-shell/src/media/subscription.rs @@ -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> { Subscription::run(stream) } fn stream() -> impl cosmic::iced::futures::Stream> { - 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::(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) } diff --git a/site/app.js b/site/app.js deleted file mode 100644 index 868a7f4..0000000 --- a/site/app.js +++ /dev/null @@ -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); - } -} diff --git a/site/content/index.md b/site/content/index.md deleted file mode 100644 index 8d75977..0000000 --- a/site/content/index.md +++ /dev/null @@ -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. diff --git a/site/handbook.css b/site/handbook.css deleted file mode 100644 index 85c87dc..0000000 --- a/site/handbook.css +++ /dev/null @@ -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; } -}