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

+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!([