diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 26b8928363..12470025d5 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -1,27 +1,217 @@ //! Types for communicating with niri via IPC. #![warn(missing_docs)] -use std::collections::HashMap; +use std::collections::BTreeMap; use std::str::FromStr; use serde::{Deserialize, Serialize}; mod socket; -pub use socket::{Socket, SOCKET_PATH_ENV}; -/// Request from client to niri. +pub use socket::{NiriSocket, SOCKET_PATH_ENV}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +#[doc(hidden)] +pub enum MaybeUnknown { + Known(T), + Unknown(U), +} + +#[doc(hidden)] +pub type MaybeJson = MaybeUnknown; + +mod private { + pub trait Sealed {} +} + +/// A request that can be sent to niri. +pub trait Request: + Serialize + for<'de> Deserialize<'de> + private::Sealed + Into +{ + /// The type of the response that niri sends for this request. + type Response: Serialize + for<'de> Deserialize<'de>; + + /// Convert the request into a RequestMessage (for serialization). + fn into_message(self) -> RequestMessage; +} + +impl From for RequestMessage { + fn from(value: R) -> Self { + value.into_message() + } +} + +macro_rules! requests { + (@$item:item$(;)?) => { $item }; + ($dollar:tt; $($(#[$m:meta])*$variant:ident($v:vis struct $request:ident$($p:tt)?) -> $response:ty;)*) => { + #[derive(Debug, Serialize, Deserialize, Clone)] + #[doc(hidden)] + pub enum RequestMessage { + $( + $(#[$m])* + $variant($request), + )* + } + + // This is for use in server code. + // Essentially just equivalent to the following: + // fn dispatch(message: RequestMessage, f: impl FnOnce(R) -> T) -> T; + // except: + // (a) rust doesn't quite support this kind of higher-order generic functions + // (b) even if it did, it would have to be sound by the Request bound, which isn't possible + // because the inherent usage is a per-type implementation which can only be sound by-example + // + // essentially this just cuts down on the server needing to enumerate all request types + #[macro_export] + #[doc(hidden)] + macro_rules! dispatch { + ($dollar message:expr, $dollar f:expr) => {{ + let message: RequestMessage = $dollar message; + match message { + $( + RequestMessage::$variant(request) => { + const fn ascribe(f: F) -> F where F: FnOnce($crate::$request) -> R { + f + } + let f = ascribe($dollar f); + f(request) + } + )* + } + }}; + } + + $( + requests!(@ + $(#[$m])* + #[derive(Debug, Serialize, Deserialize, Clone)] + $v struct $request $($p)?; + ); + + impl crate::private::Sealed for $request {} + + impl crate::Request for $request { + type Response = $response; + + fn into_message(self) -> crate::RequestMessage { + RequestMessage::$variant(self) + } + } + )* + }; +} + +/// Uninstantiable +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum Never {} + +requests!( + $; + + /// Requests the version string for the running niri instance. + Version(pub struct VersionRequest) -> String; + + /// Requests information about connected outputs. + Outputs(pub struct OutputRequest) -> BTreeMap; + + /// Requests information about the focused window. + FocusedWindow(pub struct FocusedWindowRequest) -> Option; + + /// Requests that the compositor perform an action. + Action(pub struct ActionRequest(pub Action)) -> (); + + /// Always responds with an error (for testing error handling). + ReturnError(pub struct ErrorRequest(pub String)) -> Never; +); + #[derive(Debug, Serialize, Deserialize, Clone)] -pub enum Request { - /// Request the version string for the running niri instance. - Version, - /// Request information about connected outputs. - Outputs, - /// Request information about the focused window. - FocusedWindow, - /// Perform an action. - Action(Action), - /// Respond with an error (for testing error handling). - ReturnError, +struct ErrorRepr { + #[serde(rename = "error_type")] + tag: String, + #[serde(rename = "error_message")] + message: String, +} + +macro_rules! error { + ( + $(#[$meta_enum:meta])* + $v:vis enum $name:ident { + $(#[$meta_end:meta])* + $other:ident (String)$(,)? + $( + $(#[$meta_variant:meta])* + $variant:ident = $msg:literal, + )* + } + ) => { + $(#[$meta_enum])* + $v enum $name { + $( + $(#[$meta_variant])* + $variant, + )* + $(#[$meta_end])* $other(String), + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result { + match self { + $( + $name::$variant => ErrorRepr { + tag: String::from(stringify!($variant)), + message: String::from($msg), + }, + )* + $name::$other(msg) => ErrorRepr { + tag: String::from(stringify!($other)), + message: msg.clone(), + }, + }.serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + let repr = ErrorRepr::deserialize(deserializer)?; + match repr.tag.as_str() { + $( + stringify!($variant) => Ok(Error::$variant), + )* + _ => Ok(Error::$other(repr.message)), + } + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + $( + $name::$variant => write!(f, $msg), + )* + $name::$other(msg) => write!(f, "{}", msg), + } + } + } + }; +} + +error! { + /// Errors that can occur when sending a request to niri. + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum Error { + /// An error occurred that doesn't have a specific variant. + /// This occurs when the compositor sends an error that this client doesn't know about. + Other(String), + /// The client didn't send valid JSON. + ClientBadJson = "the client didn't send valid JSON", + /// The compositor didn't understand our request. + ClientBadProtocol = "the client didn't follow the protocol; this may be caused by mismatched versions", + /// The compositor sent a request we didn't understand. + CompositorBadProtocol = "the compositor didn't follow the protocol; this may be caused by mismatched versions", + /// There is + InternalError = "an internal error occurred in the compositor", + } } /// Reply from niri to client. @@ -32,22 +222,7 @@ pub enum Request { /// * If the request does not need any particular response, it will be /// `Reply::Ok(Response::Handled)`. Kind of like an `Ok(())`. /// * Otherwise, it will be `Reply::Ok(response)` with one of the other [`Response`] variants. -pub type Reply = Result; - -/// Successful response from niri to client. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum Response { - /// A request that does not need a response was handled successfully. - Handled, - /// The version string for the running niri instance. - Version(String), - /// Information about connected outputs. - /// - /// Map from connector name to output info. - Outputs(HashMap), - /// Information about the focused window. - FocusedWindow(Option), -} +pub type Reply = Result; /// Actions that niri can perform. // Variants in this enum should match the spelling of the ones in niri-config. Most, but not all, @@ -302,6 +477,7 @@ pub struct LogicalOutput { #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum Transform { /// Untransformed. + #[serde(rename = "normal")] Normal, /// Rotated by 90°. #[serde(rename = "90")] @@ -313,12 +489,16 @@ pub enum Transform { #[serde(rename = "270")] _270, /// Flipped horizontally. + #[serde(rename = "flipped")] Flipped, /// Rotated by 90° and flipped horizontally. + #[serde(rename = "flipped-90")] Flipped90, /// Flipped vertically. + #[serde(rename = "flipped-180")] Flipped180, /// Rotated by 270° and flipped horizontally. + #[serde(rename = "flipped-270")] Flipped270, } diff --git a/niri-ipc/src/socket.rs b/niri-ipc/src/socket.rs index 67b9625cb7..a6b348527c 100644 --- a/niri-ipc/src/socket.rs +++ b/niri-ipc/src/socket.rs @@ -1,63 +1,80 @@ -//! Helper for blocking communication over the niri socket. - -use std::env; -use std::io::{self, Read, Write}; -use std::net::Shutdown; +use std::io::{self, Write}; use std::os::unix::net::UnixStream; use std::path::Path; -use crate::{Reply, Request}; +use serde_json::de::IoRead; +use serde_json::StreamDeserializer; + +use crate::{MaybeJson, Reply, Request}; /// Name of the environment variable containing the niri IPC socket path. pub const SOCKET_PATH_ENV: &str = "NIRI_SOCKET"; -/// Helper for blocking communication over the niri socket. +/// A client for the niri IPC server. /// /// This struct is used to communicate with the niri IPC server. It handles the socket connection /// and serialization/deserialization of messages. -pub struct Socket { +pub struct NiriSocket { stream: UnixStream, + responses: StreamDeserializer<'static, IoRead, serde_json::Value>, +} + +impl TryFrom for NiriSocket { + type Error = io::Error; + fn try_from(stream: UnixStream) -> io::Result { + let responses = serde_json::Deserializer::from_reader(stream.try_clone()?).into_iter(); + Ok(Self { stream, responses }) + } } -impl Socket { - /// Connects to the default niri IPC socket. +impl NiriSocket { + /// Connects to the default niri IPC socket /// - /// This is equivalent to calling [`Self::connect_to`] with the path taken from the - /// [`SOCKET_PATH_ENV`] environment variable. - pub fn connect() -> io::Result { - let socket_path = env::var_os(SOCKET_PATH_ENV).ok_or_else(|| { + /// This is equivalent to calling [Self::connect] with the value of the [SOCKET_PATH_ENV] + /// environment variable. + pub fn new() -> io::Result { + let socket_path = std::env::var_os(SOCKET_PATH_ENV).ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, format!("{SOCKET_PATH_ENV} is not set, are you running this within niri?"), ) })?; - Self::connect_to(socket_path) + Self::connect(socket_path) } - /// Connects to the niri IPC socket at the given path. - pub fn connect_to(path: impl AsRef) -> io::Result { - let stream = UnixStream::connect(path.as_ref())?; - Ok(Self { stream }) + /// Connect to the socket at the given path + /// + /// See also: [UnixStream::connect] + pub fn connect(path: impl AsRef) -> io::Result { + Self::try_from(UnixStream::connect(path.as_ref())?) } - /// Sends a request to niri and returns the response. - /// - /// Return values: + /// Handle a request to the niri IPC server /// - /// * `Ok(Ok(response))`: successful [`Response`](crate::Response) from niri - /// * `Ok(Err(message))`: error message from niri - /// * `Err(error)`: error communicating with niri - pub fn send(self, request: Request) -> io::Result { - let Self { mut stream } = self; - - let mut buf = serde_json::to_vec(&request).unwrap(); - stream.write_all(&buf)?; - stream.shutdown(Shutdown::Write)?; - - buf.clear(); - stream.read_to_end(&mut buf)?; + /// # Returns + /// - Ok(Ok([Response](crate::Response))) corresponds to a successful response from the running + /// niri instance. + /// - Ok(Err([String])) corresponds to an error received from the running niri + /// instance. + /// - Err([std::io::Error]) corresponds to an error in the IPC communication. + pub fn send_request(mut self, request: R) -> io::Result> { + let mut buf = serde_json::to_vec(&request.into_message()).unwrap(); + writeln!(buf).unwrap(); + self.stream.write_all(&buf)?; // .context("error writing IPC request")?; + self.stream.flush()?; - let reply = serde_json::from_slice(&buf)?; - Ok(reply) + if let Some(next) = self.responses.next() { + next.and_then(serde_json::from_value) + .map(|v| match v { + MaybeJson::Known(reply) => reply, + MaybeJson::Unknown(_) => Err(crate::Error::CompositorBadProtocol), + }) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + } else { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "no response from server", + )) + } } } diff --git a/src/cli.rs b/src/cli.rs index 78f9fc0e64..5e57229305 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -52,7 +52,7 @@ pub enum Sub { #[derive(Subcommand)] pub enum Msg { - /// Print the version of the running niri instance. + /// Print the version string of the running niri instance. Version, /// List connected outputs. Outputs, @@ -63,6 +63,6 @@ pub enum Msg { #[command(subcommand)] action: Action, }, - /// Request an error from the running niri instance. - RequestError, + /// Print an error message. + RequestError { message: String }, } diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 1704adfbcc..0414fd9df6 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -1,243 +1,284 @@ -use anyhow::{anyhow, bail, Context}; -use niri_ipc::{LogicalOutput, Mode, Output, Request, Response, Socket, Transform}; +use anyhow::Context; +use niri_ipc::{ + ActionRequest, Error, ErrorRequest, FocusedWindowRequest, LogicalOutput, Mode, NiriSocket, + Output, OutputRequest, Request, VersionRequest, +}; use serde_json::json; use crate::cli::Msg; use crate::utils::version; -pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { - let request = match &msg { - Msg::Version => Request::Version, - Msg::Outputs => Request::Outputs, - Msg::FocusedWindow => Request::FocusedWindow, - Msg::Action { action } => Request::Action(action.clone()), - Msg::RequestError => Request::ReturnError, - }; - - let socket = Socket::connect().context("error connecting to the niri socket")?; - - let reply = socket - .send(request) - .context("error communicating with niri")?; - - let compositor_version = match reply { - Err(_) if !matches!(msg, Msg::Version) => { - // If we got an error, it might be that the CLI is a different version from the running - // niri instance. Request the running instance version to compare and print a message. - Socket::connect() - .and_then(|socket| socket.send(Request::Version)) - .ok() +struct CompositorError { + error: niri_ipc::Error, + version: Option, +} + +trait MsgRequest: Request { + fn json(response: Self::Response) -> serde_json::Value { + json!(response) + } + + fn show_to_human(response: Self::Response); + + fn check_version() -> Option { + if let Ok(Ok(version)) = VersionRequest.send() { + Some(version) + } else { + None } - _ => None, - }; + } + + fn send(self) -> anyhow::Result> { + let socket = NiriSocket::new().context("problem initializing the socket")?; + let reply = socket.send_request(self).context("problem ")?; + Ok(reply.map_err(|error| CompositorError { + error, + version: Self::check_version(), + })) + } +} - // Default SIGPIPE so that our prints don't panic on stdout closing. +pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> { + match msg { + Msg::RequestError { message } => run(json, ErrorRequest(message)), + Msg::Version => run(json, VersionRequest), + Msg::Outputs => run(json, OutputRequest), + Msg::FocusedWindow => run(json, FocusedWindowRequest), + Msg::Action { action } => run(json, ActionRequest(action)), + } +} + +fn run(json: bool, request: R) -> anyhow::Result<()> { + let reply = request.send().context("a communication error occurred")?; + + // Piping `niri msg` into a command like `jq invalid` will cause jq to exit early + // from the invalid expression. That also causes the pipe to close, and the piped process + // receives a SIGPIPE. Normally, this would cause println! to panic, but because the error + // ultimately doesn't originate in niri, and it's not a bug in niri, the resulting backtrace is + // quite unhelpful to the user considering that the actual error (invalid jq expression) is + // already shown on the terminal. + // + // To avoid this, we ignore any SIGPIPE we receive from here on out. This can potentially + // interfere with IPC code, so we ensure that it is already finished by the time we reach this + // point. Actual errors with the IPC code are not handled by us; they're bubbled up to + // main() as Err(_). Those are separate from the pipe closing; and should be printed anyways. + // But after this point, we only really print things, so it's safe to ignore SIGPIPE. + // And since stdio panics are the *only* error path, we can be confident that there is actually + // no error path from this point on. unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } - let response = reply.map_err(|err_msg| { - // Check for CLI-server version mismatch to add helpful context. - match compositor_version { - Some(Ok(Response::Version(compositor_version))) => { - let cli_version = version(); - if cli_version != compositor_version { - eprintln!("Running niri compositor has a different version from the niri CLI:"); - eprintln!("Compositor version: {compositor_version}"); - eprintln!("CLI version: {cli_version}"); - eprintln!("Did you forget to restart niri after an update?"); + match reply { + Ok(response) => { + if json { + println!("{}", R::json(response)); + } else { + R::show_to_human(response); + } + } + Err(CompositorError { + error, + version: server_version, + }) => { + match error { + Error::ClientBadJson => { + eprintln!("Something went wrong in the CLI; the compositor says the JSON it sent was invalid") + } + Error::ClientBadProtocol => { + eprintln!("The compositor didn't understand the request sent by the CLI.") + } + Error::CompositorBadProtocol => { + eprintln!("The compositor returned a response that the CLI didn't understand.") + } + Error::InternalError => { + eprintln!("Something went wrong in the compositor. I don't know what.") + } + Error::Other(msg) => { + eprintln!("The compositor returned an error:"); eprintln!(); + eprintln!("{msg}"); } } - Some(_) => { - eprintln!("Unable to get the running niri compositor version."); - eprintln!("Did you forget to restart niri after an update?"); + + if let Some(server_version) = server_version { + let my_version = version(); + if my_version != server_version { + eprintln!(); + eprintln!("Note: niri msg was invoked with a different version of niri than the running compositor."); + eprintln!("niri msg: {my_version}"); + eprintln!("compositor: {server_version}"); + eprintln!("Did you forget to restart niri after an update?"); + } + } else { eprintln!(); + eprintln!("Note: unable to get the compositor's version."); + eprintln!("Did you forget to restart niri after an update?"); } - None => { - // Communication error, or the original request was already a version request. - // Don't add irrelevant context. - } - } - - anyhow!(err_msg).context("niri returned an error") - })?; - - match msg { - Msg::RequestError => { - bail!("unexpected response: expected an error, got {response:?}"); } - Msg::Version => { - let Response::Version(compositor_version) = response else { - bail!("unexpected response: expected Version, got {response:?}"); - }; + } - let cli_version = version(); + Ok(()) +} - if json { - println!( - "{}", - json!({ - "compositor": compositor_version, - "cli": cli_version, - }) - ); - return Ok(()); - } +impl MsgRequest for ErrorRequest { + fn json(response: Self::Response) -> serde_json::Value { + match response {} + } - if cli_version != compositor_version { - eprintln!("Running niri compositor has a different version from the niri CLI."); - eprintln!("Did you forget to restart niri after an update?"); - eprintln!(); - } + fn show_to_human(response: Self::Response) { + match response {} + } +} - println!("Compositor version: {compositor_version}"); - println!("CLI version: {cli_version}"); +impl MsgRequest for VersionRequest { + fn check_version() -> Option { + eprintln!("version"); + // If the version request fails, we can't exactly try again. + None + } + fn json(response: Self::Response) -> serde_json::Value { + json!({ + "cli": version(), + "compositor": response, + }) + } + fn show_to_human(response: Self::Response) { + let client_version = version(); + let server_version = response; + println!("niri msg is {client_version}"); + println!("the compositor is {server_version}"); + if client_version != server_version { + eprintln!(); + eprintln!("These are different"); + eprintln!("Did you forget to restart niri after an update?"); } - Msg::Outputs => { - let Response::Outputs(outputs) = response else { - bail!("unexpected response: expected Outputs, got {response:?}"); - }; + println!(); + } +} - if json { - let output = - serde_json::to_string(&outputs).context("error formatting response")?; - println!("{output}"); - return Ok(()); - } +impl MsgRequest for OutputRequest { + fn show_to_human(response: Self::Response) { + for (connector, output) in response { + let Output { + name, + make, + model, + physical_size, + modes, + current_mode, + vrr_supported, + vrr_enabled, + logical, + } = output; + + println!(r#"Output "{connector}" ({make} - {model} - {name})"#); - let mut outputs = outputs.into_iter().collect::>(); - outputs.sort_unstable_by(|a, b| a.0.cmp(&b.0)); - - for (connector, output) in outputs.into_iter() { - let Output { - name, - make, - model, - physical_size, - modes, - current_mode, - vrr_supported, - vrr_enabled, - logical, - } = output; - - println!(r#"Output "{connector}" ({make} - {model} - {name})"#); - - if let Some(current) = current_mode { - let mode = *modes - .get(current) - .context("invalid response: current mode does not exist")?; - let Mode { - width, - height, - refresh_rate, - is_preferred, - } = mode; + match current_mode.map(|idx| modes.get(idx)) { + None => println!(" Disabled"), + Some(None) => println!(" Current mode: (invalid index)"), + Some(Some(&Mode { + width, + height, + refresh_rate, + is_preferred, + })) => { let refresh = refresh_rate as f64 / 1000.; let preferred = if is_preferred { " (preferred)" } else { "" }; println!(" Current mode: {width}x{height} @ {refresh:.3} Hz{preferred}"); - } else { - println!(" Disabled"); } + } - if vrr_supported { - let enabled = if vrr_enabled { "enabled" } else { "disabled" }; - println!(" Variable refresh rate: supported, {enabled}"); - } else { - println!(" Variable refresh rate: not supported"); - } + if vrr_supported { + let enabled = if vrr_enabled { "enabled" } else { "disabled" }; + println!(" Variable refresh rate: supported, {enabled}"); + } else { + println!(" Variable refresh rate: not supported"); + } - if let Some((width, height)) = physical_size { - println!(" Physical size: {width}x{height} mm"); - } else { - println!(" Physical size: unknown"); - } + if let Some((width, height)) = physical_size { + println!(" Physical size: {width}x{height} mm"); + } else { + println!(" Physical size: unknown"); + } - if let Some(logical) = logical { - let LogicalOutput { - x, - y, - width, - height, - scale, - transform, - } = logical; - println!(" Logical position: {x}, {y}"); - println!(" Logical size: {width}x{height}"); - println!(" Scale: {scale}"); - - let transform = match transform { - Transform::Normal => "normal", - Transform::_90 => "90° counter-clockwise", - Transform::_180 => "180°", - Transform::_270 => "270° counter-clockwise", - Transform::Flipped => "flipped horizontally", - Transform::Flipped90 => "90° counter-clockwise, flipped horizontally", - Transform::Flipped180 => "flipped vertically", - Transform::Flipped270 => "270° counter-clockwise, flipped horizontally", - }; - println!(" Transform: {transform}"); - } + if let Some(logical) = logical { + let LogicalOutput { + x, + y, + width, + height, + scale, + transform, + } = logical; + println!(" Logical position: {x}, {y}"); + println!(" Logical size: {width}x{height}"); + println!(" Scale: {scale}"); - println!(" Available modes:"); - for (idx, mode) in modes.into_iter().enumerate() { - let Mode { - width, - height, - refresh_rate, - is_preferred, - } = mode; - let refresh = refresh_rate as f64 / 1000.; + let transform = match transform { + niri_ipc::Transform::Normal => "normal", + niri_ipc::Transform::_90 => "90° counter-clockwise", + niri_ipc::Transform::_180 => "180°", + niri_ipc::Transform::_270 => "270° counter-clockwise", + niri_ipc::Transform::Flipped => "flipped horizontally", + niri_ipc::Transform::Flipped90 => "90° counter-clockwise, flipped horizontally", + niri_ipc::Transform::Flipped180 => "flipped vertically", + niri_ipc::Transform::Flipped270 => { + "270° counter-clockwise, flipped horizontally" + } + }; + println!(" Transform: {transform}"); + } - let is_current = Some(idx) == current_mode; - let qualifier = match (is_current, is_preferred) { - (true, true) => " (current, preferred)", - (true, false) => " (current)", - (false, true) => " (preferred)", - (false, false) => "", - }; + println!(" Available modes:"); + for (idx, mode) in modes.into_iter().enumerate() { + let Mode { + width, + height, + refresh_rate, + is_preferred, + } = mode; + let refresh = refresh_rate as f64 / 1000.; - println!(" {width}x{height}@{refresh:.3}{qualifier}"); - } - println!(); - } - } - Msg::FocusedWindow => { - let Response::FocusedWindow(window) = response else { - bail!("unexpected response: expected FocusedWindow, got {response:?}"); - }; + let is_current = Some(idx) == current_mode; + let qualifier = match (is_current, is_preferred) { + (true, true) => " (current, preferred)", + (true, false) => " (current)", + (false, true) => " (preferred)", + (false, false) => "", + }; - if json { - let window = serde_json::to_string(&window).context("error formatting response")?; - println!("{window}"); - return Ok(()); + println!(" {width}x{height}@{refresh:.3}{qualifier}"); } + println!(); + } + } +} - if let Some(window) = window { - println!("Focused window:"); +impl MsgRequest for FocusedWindowRequest { + fn show_to_human(response: Self::Response) { + if let Some(window) = response { + println!("Focused window:"); - if let Some(title) = window.title { - println!(" Title: \"{title}\""); - } else { - println!(" Title: (unset)"); - } + if let Some(title) = window.title { + println!(" Title: \"{title}\""); + } else { + println!(" Title: (unset)"); + } - if let Some(app_id) = window.app_id { - println!(" App ID: \"{app_id}\""); - } else { - println!(" App ID: (unset)"); - } + if let Some(app_id) = window.app_id { + println!(" App ID: \"{app_id}\""); } else { - println!("No window is focused."); + println!(" App ID: (unset)"); } - } - Msg::Action { .. } => { - let Response::Handled = response else { - bail!("unexpected response: expected Handled, got {response:?}"); - }; + } else { + println!("No window is focused."); } } +} - Ok(()) +impl MsgRequest for ActionRequest { + fn show_to_human(response: Self::Response) { + response + } } diff --git a/src/ipc/server.rs b/src/ipc/server.rs index 5e18c16a78..d819355444 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -7,8 +8,11 @@ use anyhow::Context; use calloop::io::Async; use directories::BaseDirs; use futures_util::io::{AsyncReadExt, BufReader}; -use futures_util::{AsyncBufReadExt, AsyncWriteExt}; -use niri_ipc::{Reply, Request, Response}; +use futures_util::{AsyncBufReadExt, AsyncWriteExt, StreamExt}; +use niri_ipc::{ + ActionRequest, Error, ErrorRequest, FocusedWindowRequest, MaybeJson, MaybeUnknown, + OutputRequest, Reply, Request, RequestMessage, VersionRequest, +}; use smithay::desktop::Window; use smithay::reexports::calloop::generic::Generic; use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction}; @@ -107,44 +111,95 @@ fn on_new_ipc_client(state: &mut State, stream: UnixStream) { async fn handle_client(ctx: ClientCtx, stream: Async<'_, UnixStream>) -> anyhow::Result<()> { let (read, mut write) = stream.split(); - let mut buf = String::new(); - // Read a single line to allow extensibility in the future to keep reading. - BufReader::new(read) - .read_line(&mut buf) - .await - .context("error reading request")?; + // note that we can't use the stream json deserializer here + // because the stream is asynchronous and the deserializer doesn't support that + // https://github.com/serde-rs/json/issues/575 - let request = serde_json::from_str(&buf) - .context("error parsing request") - .map_err(|err| err.to_string()); - let requested_error = matches!(request, Ok(Request::ReturnError)); + let mut lines = BufReader::new(read).lines(); - let reply = request.and_then(|request| process(&ctx, request)); + let line = match lines.next().await.unwrap_or(Err(io::Error::new(io::ErrorKind::UnexpectedEof, "Unreachable; BufReader returned None but when the stream ends, the connection should be reset"))) { + Ok(line) => line, + Err(err) if err.kind() == io::ErrorKind::ConnectionReset => return Ok(()), + Err(err) => return Err(err).context("error reading line"), + }; + + let reply = process(&ctx, line); if let Err(err) = &reply { - if !requested_error { - warn!("error processing IPC request: {err:?}"); - } + warn!("error processing IPC request: {err:?}"); } - let buf = serde_json::to_vec(&reply).context("error formatting reply")?; + let mut buf = serde_json::to_vec(&reply).context("error formatting reply")?; + writeln!(buf).unwrap(); write.write_all(&buf).await.context("error writing reply")?; + write.flush().await.context("error flushing reply")?; + + // We do not check for more lines at this moment. + // Dropping the stream will reset the connection before we read them. + // For now, a client should not be sending more than one request per connection. Ok(()) } -fn process(ctx: &ClientCtx, request: Request) -> Reply { - let response = match request { - Request::ReturnError => return Err(String::from("example compositor error")), - Request::Version => Response::Version(version()), - Request::Outputs => { - let ipc_outputs = ctx.ipc_outputs.lock().unwrap().clone(); - Response::Outputs(ipc_outputs) +trait HandleRequest: Request { + fn handle(self, ctx: &ClientCtx) -> Reply; +} + +fn process(ctx: &ClientCtx, line: String) -> Reply { + let deserialized: MaybeJson = serde_json::from_str(&line).map_err(|err| { + warn!("error parsing IPC request: {err:?}"); + Error::ClientBadJson + })?; + + match deserialized { + MaybeUnknown::Known(request) => niri_ipc::dispatch!(request, |req| { + req.handle(ctx).and_then(|v| { + serde_json::to_value(v).map_err(|err| { + warn!("error serializing response to IPC request: {err:?}"); + Error::InternalError + }) + }) + }), + MaybeUnknown::Unknown(payload) => { + warn!("client sent an invalid payload: {payload}"); + Err(Error::ClientBadProtocol) } - Request::FocusedWindow => { - let window = ctx.ipc_focused_window.lock().unwrap().clone(); - let window = window.map(|window| { + } +} + +impl HandleRequest for ErrorRequest { + fn handle(self, _ctx: &ClientCtx) -> Reply { + Err(Error::Other(self.0)) + } +} + +impl HandleRequest for VersionRequest { + fn handle(self, _ctx: &ClientCtx) -> Reply { + Ok(version()) + } +} + +impl HandleRequest for OutputRequest { + fn handle(self, ctx: &ClientCtx) -> Reply { + Ok(ctx + .ipc_outputs + .lock() + .unwrap() + .clone() + .into_iter() + .collect()) + } +} + +impl HandleRequest for FocusedWindowRequest { + fn handle(self, ctx: &ClientCtx) -> Reply { + Ok(ctx + .ipc_focused_window + .lock() + .unwrap() + .clone() + .map(|window| { let wl_surface = window.toplevel().expect("no X11 support").wl_surface(); with_states(wl_surface, |states| { let role = states @@ -159,17 +214,16 @@ fn process(ctx: &ClientCtx, request: Request) -> Reply { app_id: role.app_id.clone(), } }) - }); - Response::FocusedWindow(window) - } - Request::Action(action) => { - let action = niri_config::Action::from(action); - ctx.event_loop.insert_idle(move |state| { - state.do_action(action, false); - }); - Response::Handled - } - }; + })) + } +} - Ok(response) +impl HandleRequest for ActionRequest { + fn handle(self, ctx: &ClientCtx) -> Reply { + let action = niri_config::Action::from(self.0); + ctx.event_loop.insert_idle(move |state| { + state.do_action(action, false); + }); + Ok(()) + } }