diff --git a/Cargo.lock b/Cargo.lock index 32d5947..67162b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,6 +386,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "x11", ] [[package]] @@ -935,6 +936,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "x11" +version = "2.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2638d5b9c17ac40575fb54bb461a4b1d2a8d1b4ffcc4ff237d254ec59ddeb82" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "xml-rs" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index 1a69cc1..fa4fadd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ tokio-stream = "0.1.11" toml = "0.5.10" tracing = "0.1.37" tracing-subscriber = { version = "0.3.16", features = ["env-filter", "fmt"] } +x11 = { version = "2.20.1", features = ["xlib", "xrandr"] } diff --git a/src/main.rs b/src/main.rs index b075c13..f5f5472 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ mod browser; mod config; mod marionette; +mod monitor; mod mqtt; mod session; +#[cfg(unix)] +mod x11; use tokio::signal::unix::{self, SignalKind}; use tracing::info; diff --git a/src/marionette/message.rs b/src/marionette/message.rs index 4066213..b7af4a0 100644 --- a/src/marionette/message.rs +++ b/src/marionette/message.rs @@ -83,6 +83,21 @@ pub struct NavigateParams { pub url: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct CloseWindowParams { + pub handle: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct SwitchToWindowParams { + pub handle: String, + pub focus: bool, +} + #[derive(Debug, Serialize)] #[serde(tag = "command", content = "params")] pub enum Command { @@ -94,4 +109,12 @@ pub enum Command { Navigate(NavigateParams), #[serde(rename = "WebDriver:GetCurrentURL")] GetCurrentUrl, + #[serde(rename = "WebDriver:GetWindowHandles")] + GetWindowHandles, + #[serde(rename = "WebDriver:CloseWindow")] + CloseWindow(CloseWindowParams), + #[serde(rename = "WebDriver:NewWindow")] + NewWindow, + #[serde(rename = "WebDriver:SwitchToWindow")] + SwitchToWindow(SwitchToWindowParams), } diff --git a/src/marionette/mod.rs b/src/marionette/mod.rs index ee8c021..89ec71c 100644 --- a/src/marionette/mod.rs +++ b/src/marionette/mod.rs @@ -18,8 +18,9 @@ use tracing::{debug, error, trace, warn}; pub use error::{CommandError, ConnectionError, ErrorResponse, MessageError}; use message::{ - Command, GetCurrentUrlResponse, GetTitleResponse, Hello, NavigateParams, - NewSessionParams, NewSessionResponse, + CloseWindowParams, Command, GetCurrentUrlResponse, GetTitleResponse, + Hello, NavigateParams, NewSessionParams, NewSessionResponse, + SwitchToWindowParams, }; #[derive(Debug, Deserialize, Serialize)] @@ -167,6 +168,21 @@ impl Marionette { Self { conn } } + pub async fn close_window( + &mut self, + handle: impl Into, + ) -> Result<(), CommandError> { + let res: Vec = self + .conn + .send_message(Command::CloseWindow(CloseWindowParams { + handle: handle.into(), + })) + .await? + .unwrap(); + debug!("Received message: {:?}", res); + Ok(()) + } + pub async fn get_title(&mut self) -> Result { let res: GetTitleResponse = self.conn.send_message(Command::GetTitle).await?.unwrap(); @@ -175,12 +191,27 @@ impl Marionette { } pub async fn get_current_url(&mut self) -> Result { - let res: GetCurrentUrlResponse = - self.conn.send_message(Command::GetCurrentUrl).await?.unwrap(); + let res: GetCurrentUrlResponse = self + .conn + .send_message(Command::GetCurrentUrl) + .await? + .unwrap(); debug!("Received message: {:?}", res); Ok(res.value) } + pub async fn get_window_handles( + &mut self, + ) -> Result, CommandError> { + let res = self + .conn + .send_message(Command::GetWindowHandles) + .await? + .unwrap(); + debug!("Received message: {:?}", res); + Ok(res) + } + pub async fn navigate(&mut self, url: U) -> Result<(), CommandError> where U: Into, @@ -208,4 +239,28 @@ impl Marionette { debug!("Received message: {:?}", res); Ok(res) } + + pub async fn new_window(&mut self) -> Result { + let res: String = + self.conn.send_message(Command::NewWindow).await?.unwrap(); + debug!("Received message: {:?}", res); + Ok(res) + } + + pub async fn switch_to_window( + &mut self, + handle: String, + focus: bool, + ) -> Result<(), CommandError> { + let res: serde_json::Value = self + .conn + .send_message(Command::SwitchToWindow(SwitchToWindowParams { + handle, + focus, + })) + .await? + .unwrap(); + debug!("Received message: {:?}", res); + Ok(()) + } } diff --git a/src/monitor.rs b/src/monitor.rs new file mode 100644 index 0000000..b077d6a --- /dev/null +++ b/src/monitor.rs @@ -0,0 +1,7 @@ +#[derive(Debug)] +pub struct Monitor { + pub name: String, + pub width: u32, + pub height: u32, +} + diff --git a/src/session.rs b/src/session.rs index 3d193cd..1142864 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,12 +1,16 @@ +use std::collections::HashMap; use std::time::Duration; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, info, trace, warn}; use crate::browser::{Browser, BrowserError}; use crate::config::Configuration; use crate::marionette::error::{CommandError, ConnectionError}; use crate::marionette::{Marionette, MarionetteConnection}; +use crate::monitor::Monitor; use crate::mqtt::{Message, MqttClient, MqttPublisher}; +#[cfg(unix)] +use crate::x11::{xrandr, Display}; #[derive(Debug)] pub enum SessionError { @@ -94,6 +98,14 @@ impl Session { } pub async fn run(mut self) { + let windows = match self.init_windows().await { + Ok(w) => w, + Err(e) => { + error!("Failed to initialize browser windows: {}", e); + return; + } + }; + let mut client = MqttClient::new(&self.config).unwrap(); loop { if let Err(e) = client.connect().await { @@ -108,15 +120,39 @@ impl Session { } break; } + let handler = MessageHandler { marionette: &mut self.marionette, + windows, }; client.run(handler).await; } + + async fn init_windows( + &mut self, + ) -> Result, SessionError> { + debug!("Getting monitor configuration"); + let monitors = get_monitors(); + let mut handles = self.marionette.get_window_handles().await?; + for handle in handles.iter().take(handles.len() - 1) { + self.marionette.close_window(handle).await?; + } + let mut windowmap = HashMap::new(); + let mut window = Some(handles.remove(handles.len() - 1)); + for monitor in monitors { + if window.is_none() { + window = Some(self.marionette.new_window().await?); + } + windowmap.insert(monitor.name, window.take().unwrap()); + } + trace!("Built window map: {:?}", windowmap); + Ok(windowmap) + } } pub struct MessageHandler<'a> { marionette: &'a mut Marionette, + windows: HashMap, } #[async_trait::async_trait] @@ -131,6 +167,21 @@ impl<'a> crate::mqtt::MessageHandler for MessageHandler<'a> { } Some(s) => s, }; + if let Some(window) = self.windows.get(*screen) { + debug!("Switching to window {}", window); + if let Err(e) = + self.marionette.switch_to_window(window.into(), false).await + { + error!( + "Failed to switch to window on screen {}: {}", + screen, e + ); + return; + } + } else { + error!("Invalid navigate request: unknown screen {}", screen); + return; + } debug!("Handling navigate request: {}", url); info!("Navigate screen {} to {}", screen, url); if let Err(e) = self.marionette.navigate(url.to_string()).await { @@ -154,3 +205,16 @@ impl<'a> crate::mqtt::MessageHandler for MessageHandler<'a> { } } } + +#[cfg(unix)] +fn get_monitors() -> Vec { + let display = Display::open().unwrap(); + xrandr::get_monitors(&display) + .iter() + .map(|m| Monitor { + name: m.name().unwrap().to_string(), + width: m.width(), + height: m.height(), + }) + .collect() +} diff --git a/src/x11/mod.rs b/src/x11/mod.rs new file mode 100644 index 0000000..373ed86 --- /dev/null +++ b/src/x11/mod.rs @@ -0,0 +1,87 @@ +pub mod xrandr; + +use std::ffi::CStr; +use std::os::raw::{c_int, c_ulong}; + +use x11::xlib::{ + XCloseDisplay, XDefaultScreen, XDisplayName, XOpenDisplay, XRootWindow, + _XDisplay, +}; + +/// Error returned if connecting to the X server fails +#[derive(Debug)] +pub struct OpenDisplayError; + +/// Wrapper for an X display pointer +#[derive(Debug)] +pub struct Display { + display: *mut _XDisplay, +} + +impl Display { + /// Open a connection to the X server + /// + /// If the connection succeeds, a [`Display`] is returned. Otherwise, an + /// [`OpenDisplayError`] is returned. + pub fn open() -> Result { + let display = unsafe { XOpenDisplay(std::ptr::null()) }; + if display.is_null() { + Err(OpenDisplayError) + } else { + Ok(Self { display }) + } + } + + /// Return the name of the X server display + /// + /// If the display name cannot be determined, an empty string is returned. + pub fn name() -> String { + let name = unsafe { CStr::from_ptr(XDisplayName(std::ptr::null())) }; + let name = name.to_str().unwrap_or(""); + String::from(name) + } + + /// Return the default screen number of the display + pub fn default_screen(&self) -> c_int { + unsafe { XDefaultScreen(self.display) } + } + + /// Return the ID of the root window + pub fn root_window(&self) -> c_ulong { + let screen = self.default_screen(); + unsafe { XRootWindow(self.display, screen) } + } +} + +impl Drop for Display { + fn drop(&mut self) { + unsafe { XCloseDisplay(self.display) }; + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_display_open() { + let _dpy = Display::open().unwrap(); + } + + #[test] + fn test_display_name() { + assert!(!Display::name().is_empty()); + } + + #[test] + fn test_default_screen() { + let dpy = Display::open().unwrap(); + assert!(dpy.default_screen() > -1); + } + + #[test] + fn test_root_window() { + let dpy = Display::open().unwrap(); + assert!(dpy.root_window() > 0); + } +} diff --git a/src/x11/xrandr.rs b/src/x11/xrandr.rs new file mode 100644 index 0000000..f3043c1 --- /dev/null +++ b/src/x11/xrandr.rs @@ -0,0 +1,80 @@ +/// High-level interface for the X11 RandR extension +/// +/// The RandR extension for X11 provides additional capabilities for +/// screens and monitors, including reszing and rotating outputs. This +/// module provides types and functions for inspecting X11 monitor +/// configuration using the RandR extension. +use std::ffi::CStr; + +use x11::xlib::XGetAtomName; +use x11::xrandr::{XRRGetMonitors, XRRMonitorInfo}; + +use super::Display; + +/// Monitor information from the X display server +pub struct XMonitor { + name: String, + monitor: XRRMonitorInfo, +} + +impl XMonitor { + pub fn width(&self) -> u32 { + self.monitor.width as u32 + } + + pub fn height(&self) -> u32 { + self.monitor.height as u32 + } + + pub fn name(&self) -> Option<&str> { + Some(&self.name) + } +} + +/// Return information about the monitors attached to an X display +/// +/// This function returns a vector containing [`XMonitor`] structures +/// These structures an be used to inspect monitor properties. +pub fn get_monitors(display: &Display) -> Vec { + let mut num_monitors = 0; + let ptr_monitors = unsafe { + XRRGetMonitors( + display.display, + display.root_window(), + 0, + &mut num_monitors, + ) + }; + + let mut monitors = Vec::with_capacity(num_monitors as usize); + for i in 0..num_monitors { + let monitor = unsafe { *ptr_monitors.offset(i as isize) }; + let name = unsafe { + CStr::from_ptr(XGetAtomName(display.display, monitor.name)) + .to_str() + .map_or_else(|_| i.to_string(), String::from) + }; + monitors.push(XMonitor { name, monitor }); + } + monitors +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_get_monitors() { + let dpy = Display::open().unwrap(); + let monitors = get_monitors(&dpy); + assert!(!monitors.is_empty()); + } + + #[test] + fn test_monitor_name() { + let dpy = Display::open().unwrap(); + let monitors = get_monitors(&dpy); + let monitor = &monitors[0]; + assert!(!monitor.name().unwrap().is_empty()); + } +}