Modernize feresectl and add bounded IPC event streaming
This commit is contained in:
1 parent
cfdd2a148a
commit
97026a0db7
39 files changed
+1671
-254
No files matched your search
@@ -0,0 +1,134 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Event schema version, independent of command framing versions.
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
pub struct ConfigState {
|
||||
pub revision: u64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct LockState {
|
||||
pub phase: String,
|
||||
pub sleeping: bool,
|
||||
}
|
||||
|
||||
/// A coherent logical desktop view. Desktop objects are redacted while locked.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Snapshot {
|
||||
pub outputs: Value,
|
||||
pub workspaces: Value,
|
||||
pub windows: Value,
|
||||
pub focus: Value,
|
||||
pub config: ConfigState,
|
||||
pub theme: Value,
|
||||
pub lock: LockState,
|
||||
}
|
||||
|
||||
/// Replacement values for each changed domain; not animation frames or client damage.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Change {
|
||||
Snapshot { desktop: Snapshot },
|
||||
OutputsChanged { outputs: Value },
|
||||
WorkspacesChanged { workspaces: Value },
|
||||
WindowsChanged { windows: Value },
|
||||
FocusChanged { focus: Value },
|
||||
ConfigChanged { config: ConfigState },
|
||||
ThemeChanged { theme: Value },
|
||||
LockChanged { lock: LockState },
|
||||
}
|
||||
|
||||
/// All changes from one dispatch share a generation. The last marks its end.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub struct Event {
|
||||
pub version: u32,
|
||||
pub generation: u64,
|
||||
pub last: bool,
|
||||
#[serde(flatten)]
|
||||
pub change: Change,
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.version == VERSION {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Unsupported IPC event version {}", self.version))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub fn changes_since(&self, previous: &Self) -> Vec<Change> {
|
||||
let mut changes = Vec::new();
|
||||
if self.outputs != previous.outputs {
|
||||
changes.push(Change::OutputsChanged {
|
||||
outputs: self.outputs.clone(),
|
||||
});
|
||||
}
|
||||
if self.workspaces != previous.workspaces {
|
||||
changes.push(Change::WorkspacesChanged {
|
||||
workspaces: self.workspaces.clone(),
|
||||
});
|
||||
}
|
||||
if self.windows != previous.windows {
|
||||
changes.push(Change::WindowsChanged {
|
||||
windows: self.windows.clone(),
|
||||
});
|
||||
}
|
||||
if self.focus != previous.focus {
|
||||
changes.push(Change::FocusChanged {
|
||||
focus: self.focus.clone(),
|
||||
});
|
||||
}
|
||||
if self.config != previous.config {
|
||||
changes.push(Change::ConfigChanged {
|
||||
config: self.config.clone(),
|
||||
});
|
||||
}
|
||||
if self.theme != previous.theme {
|
||||
changes.push(Change::ThemeChanged {
|
||||
theme: self.theme.clone(),
|
||||
});
|
||||
}
|
||||
if self.lock != previous.lock {
|
||||
changes.push(Change::LockChanged {
|
||||
lock: self.lock.clone(),
|
||||
});
|
||||
}
|
||||
changes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn events_round_trip_with_an_independent_schema_version() {
|
||||
let event = Event {
|
||||
version: VERSION,
|
||||
generation: 42,
|
||||
last: true,
|
||||
change: Change::FocusChanged {
|
||||
focus: serde_json::json!({"window": 7}),
|
||||
},
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
crate::write_frame(&mut bytes, &event).unwrap();
|
||||
let decoded: Event = crate::read_frame(&mut bytes.as_slice()).unwrap();
|
||||
assert_eq!(decoded, event);
|
||||
decoded.validate().unwrap();
|
||||
assert!(
|
||||
Event {
|
||||
version: VERSION + 1,
|
||||
..event
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ use std::{fmt, io};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod events;
|
||||
pub mod media;
|
||||
pub mod socket;
|
||||
pub mod theme;
|
||||
pub mod xwayland;
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolve an IPC target: explicit path, FERESE_SOCKET, then the runtime default.
|
||||
pub fn resolve(explicit: Option<&Path>) -> io::Result<PathBuf> {
|
||||
resolve_with(
|
||||
explicit,
|
||||
std::env::var_os("FERESE_SOCKET").as_deref(),
|
||||
std::env::var_os("XDG_RUNTIME_DIR").as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_with(explicit: Option<&Path>, socket: Option<&OsStr>, runtime: Option<&OsStr>) -> io::Result<PathBuf> {
|
||||
if let Some(path) = explicit {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "IPC socket path is empty"));
|
||||
}
|
||||
return Ok(path.to_owned());
|
||||
}
|
||||
if let Some(socket) = socket.filter(|value| !value.is_empty()) {
|
||||
return Ok(PathBuf::from(socket));
|
||||
}
|
||||
runtime
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|directory| PathBuf::from(directory).join("ferese/control.sock"))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_nested_target_wins_over_the_host_environment() {
|
||||
let runtime = Some(OsStr::new("/run/user/1000"));
|
||||
let host = OsStr::new("/run/user/1000/ferese/control.sock");
|
||||
let nested = Path::new("/tmp/nested/ferese/control.sock");
|
||||
assert_eq!(resolve_with(Some(nested), Some(host), runtime).unwrap(), nested);
|
||||
assert_eq!(resolve_with(None, Some(nested.as_os_str()), runtime).unwrap(), nested);
|
||||
assert_eq!(resolve_with(None, None, runtime).unwrap(), Path::new(host));
|
||||
assert_eq!(
|
||||
resolve_with(None, Some(OsStr::new("")), runtime).unwrap(),
|
||||
Path::new(host)
|
||||
);
|
||||
assert!(resolve_with(None, None, None).is_err());
|
||||
assert!(resolve_with(Some(Path::new("")), Some(host), runtime).is_err());
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,7 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
pub fn socket_path() -> io::Result<PathBuf> {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|directory| PathBuf::from(directory).join("ferese/control.sock"))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
|
||||
crate::socket::resolve(None)
|
||||
}
|
||||
|
||||
pub struct Connection {
|
||||
@@ -59,7 +56,11 @@ pub struct Connection {
|
||||
|
||||
impl Connection {
|
||||
pub fn connect() -> io::Result<Self> {
|
||||
let stream = UnixStream::connect(socket_path()?)?;
|
||||
Self::connect_to(socket_path()?)
|
||||
}
|
||||
|
||||
pub fn connect_to(path: impl AsRef<std::path::Path>) -> io::Result<Self> {
|
||||
let stream = UnixStream::connect(path)?;
|
||||
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
Ok(Self { stream, id: 0 })
|
||||
|
||||
Reference in new issue
Block a user