Skip to main content

console_relay/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Code to launch a terminal emulator for relaying input/output.
5
6#![forbid(unsafe_code)]
7
8mod unix;
9mod windows;
10
11use anyhow::Context as _;
12use futures::AsyncRead;
13use futures::AsyncWrite;
14use futures::AsyncWriteExt;
15use futures::executor::block_on;
16use futures::io::AllowStdIo;
17use futures::io::AsyncReadExt;
18use pal_async::driver::Driver;
19use pal_async::local::block_with_io;
20use std::borrow::Cow;
21use std::ffi::OsStr;
22use std::path::Path;
23use std::path::PathBuf;
24use std::pin::Pin;
25use std::process::Command;
26use std::task::Context;
27use term::raw_stdout;
28
29#[derive(Default)]
30/// Options to configure a new console window during launch.
31pub struct ConsoleLaunchOptions {
32    /// If supplied, sets the title of the console window.
33    pub window_title: Option<String>,
34}
35
36/// Relay stdin/stdout to the given async read/write halves.
37///
38/// Uses sync stdio (with a separate thread for input) because polling for stdio
39/// readiness is difficult, especially on Windows.
40async fn relay_stdio(
41    read: impl AsyncRead + Unpin,
42    mut write: impl AsyncWrite + Unpin + Send + 'static,
43    console_title: &str,
44) -> anyhow::Result<()> {
45    crossterm::terminal::enable_raw_mode().expect("failed to set raw console mode");
46    if let Err(err) = crossterm::execute!(
47        std::io::stdout(),
48        crossterm::terminal::SetTitle(console_title)
49    ) {
50        tracing::warn!("failed to set console title: {}", err);
51    }
52
53    std::thread::Builder::new()
54        .name("input_thread".into())
55        .spawn(move || {
56            block_on(futures::io::copy(
57                AllowStdIo::new(std::io::stdin()),
58                &mut write,
59            ))
60        })
61        .unwrap();
62
63    futures::io::copy(read, &mut AllowStdIo::new(raw_stdout())).await?;
64    // Don't wait for the input thread, since it is probably blocking in the stdin read.
65    Ok(())
66}
67
68/// Synchronously relays stdio to the pipe (Windows) or socket (Unix) pointed to
69/// by `path`.
70pub fn relay_console(path: &Path, console_title: &str) -> anyhow::Result<()> {
71    block_with_io(async |driver| {
72        #[cfg(unix)]
73        let (read, write) = {
74            let pipe = pal_async::socket::PolledSocket::connect_unix(&driver, path)
75                .await
76                .context("failed to connect to console socket")?;
77            pipe.split()
78        };
79        #[cfg(windows)]
80        let (read, write) = {
81            let pipe = std::fs::OpenOptions::new()
82                .read(true)
83                .write(true)
84                .open(path)
85                .context("failed to connect to console pipe")?;
86            let pipe = pal_async::pipe::PolledPipe::new(&driver, pipe)
87                .context("failed to create polled pipe")?;
88            AsyncReadExt::split(pipe)
89        };
90
91        relay_stdio(read, write, console_title).await
92    })
93}
94
95/// Synchronously relays stdio to an already-connected pipe.
96///
97/// This is useful when the caller is the pipe server rather than the client.
98#[cfg(windows)]
99pub fn relay_console_pipe(pipe: std::fs::File, console_title: &str) -> anyhow::Result<()> {
100    block_with_io(async |driver| {
101        let pipe = pal_async::pipe::PolledPipe::new(&driver, pipe)
102            .context("failed to create polled pipe")?;
103        let (read, write) = AsyncReadExt::split(pipe);
104        relay_stdio(read, write, console_title).await
105    })
106}
107
108struct App<'a> {
109    path: Cow<'a, Path>,
110    args: Vec<Cow<'a, OsStr>>,
111}
112
113impl<'a, T: AsRef<OsStr> + ?Sized> From<&'a T> for App<'a> {
114    fn from(value: &'a T) -> Self {
115        Self {
116            path: Path::new(value).into(),
117            args: Vec::new(),
118        }
119    }
120}
121
122fn choose_terminal_apps(app: Option<&Path>) -> Vec<App<'_>> {
123    // If a specific app was specified, use it with no fallbacks.
124    if let Some(app) = app {
125        return vec![app.into()];
126    }
127
128    let mut apps = Vec::new();
129
130    let env_set = |key| std::env::var_os(key).is_some_and(|x| !x.is_empty());
131
132    // If we're running in tmux, use tmux.
133    if env_set("TMUX") {
134        apps.push(App {
135            args: vec![OsStr::new("new-window").into()],
136            .."tmux".into()
137        });
138    }
139
140    // If there's an X11 display, use x-terminal-emulator or xterm.
141    if cfg!(unix) && env_set("DISPLAY") {
142        apps.push("x-terminal-emulator".into());
143        apps.push("xterm".into());
144    }
145
146    // On Windows, use Windows Terminal or conhost.
147    if cfg!(windows) {
148        apps.push("wt.exe".into());
149        apps.push("conhost.exe".into());
150    }
151
152    apps
153}
154
155/// Launches the terminal application `app` (or the system default), and launch
156/// OpenVMM as a child of that to relay the data in the pipe/socket referred to
157/// by `path`. Additional launch options can be specified with `launch_options`.
158pub fn launch_console(
159    app: Option<&Path>,
160    path: &Path,
161    launch_options: ConsoleLaunchOptions,
162) -> anyhow::Result<()> {
163    let apps = choose_terminal_apps(app);
164
165    for app in &apps {
166        let mut command = Command::new(app.path.as_ref());
167        command.args(&app.args);
168        add_argument_separator(&mut command, app.path.as_ref());
169        let mut child_builder = command
170            .arg(std::env::current_exe().context("could not determine current exe path")?)
171            .arg("--relay-console-path")
172            .arg(path);
173
174        // If a title was specified, pass it to the terminal spawn.
175        if let Some(title) = &launch_options.window_title {
176            child_builder = child_builder.arg("--relay-console-title").arg(title);
177        }
178
179        let child = child_builder
180            .stdin(std::process::Stdio::null())
181            .stdout(std::process::Stdio::null())
182            .spawn();
183
184        match child {
185            Ok(mut child) => {
186                std::thread::Builder::new()
187                    .name("console_waiter".into())
188                    .spawn(move || {
189                        let _ = child.wait();
190                    })
191                    .unwrap();
192
193                return Ok(());
194            }
195            Err(err) if err.kind() == std::io::ErrorKind::NotFound && apps.len() != 1 => continue,
196            Err(err) => Err(err)
197                .with_context(|| format!("failed to launch terminal {}", app.path.display()))?,
198        };
199    }
200
201    anyhow::bail!("could not find a terminal emulator");
202}
203
204/// Adds the terminal-specific separator between terminal arguments and the
205/// process to launch.
206fn add_argument_separator(command: &mut Command, app: &Path) {
207    if let Some(file_name) = app.file_name().and_then(|s| s.to_str()) {
208        let arg = match file_name {
209            "xterm" | "rxvt" | "urxvt" | "x-terminal-emulator" => "-e",
210            _ => "--",
211        };
212        command.arg(arg);
213    };
214}
215
216/// Computes a random console path (pipe path for Windows, Unix socket path for Unix).
217pub fn random_console_path() -> PathBuf {
218    #[cfg(windows)]
219    let mut path = PathBuf::from("\\\\.\\pipe");
220    #[cfg(unix)]
221    let mut path = std::env::temp_dir();
222
223    let mut random = [0; 16];
224    getrandom::fill(&mut random).expect("rng failure");
225    path.push(u128::from_ne_bytes(random).to_string());
226
227    path
228}
229
230/// An external console window.
231///
232/// To write to the console, use methods from [`AsyncWrite`]. To read from the
233/// console, use methods from [`AsyncRead`].
234pub struct Console {
235    #[cfg(windows)]
236    sys: windows::WindowsNamedPipeConsole,
237    #[cfg(unix)]
238    sys: unix::UnixSocketConsole,
239}
240
241impl Console {
242    /// Launches a new terminal emulator and returns an object used to
243    /// read/write to the console of that window.
244    ///
245    /// If `app` is `None`, the system default terminal emulator is used.
246    ///
247    /// The terminal emulator will relaunch the current executable with the
248    /// `--relay-console-path` argument to specify the path of the pipe/socket
249    /// used to relay data. Call [`relay_console`] with that path in your `main`
250    /// function.
251    pub fn new(
252        driver: impl Driver,
253        app: Option<&Path>,
254        launch_options: Option<ConsoleLaunchOptions>,
255    ) -> anyhow::Result<Self> {
256        let path = random_console_path();
257        let this = Self::new_from_path(driver, &path)?;
258        launch_console(app, &path, launch_options.unwrap_or_default())
259            .context("failed to launch console")?;
260        Ok(this)
261    }
262
263    fn new_from_path(driver: impl Driver, path: &Path) -> anyhow::Result<Self> {
264        #[cfg(windows)]
265        let sys = windows::WindowsNamedPipeConsole::new(Box::new(driver), path)
266            .context("failed to create console pipe")?;
267        #[cfg(unix)]
268        let sys = unix::UnixSocketConsole::new(Box::new(driver), path)
269            .context("failed to create console socket")?;
270        Ok(Console { sys })
271    }
272
273    /// Relays the console contents to and from `io`.
274    pub async fn relay(&mut self, io: impl AsyncRead + AsyncWrite) -> anyhow::Result<()> {
275        let (pipe_recv, mut pipe_send) = { AsyncReadExt::split(self) };
276
277        let (socket_recv, mut socket_send) = io.split();
278
279        let task_a = async move {
280            let r = futures::io::copy(pipe_recv, &mut socket_send).await;
281            let _ = socket_send.close().await;
282            r
283        };
284        let task_b = async move {
285            let r = futures::io::copy(socket_recv, &mut pipe_send).await;
286            let _ = pipe_send.close().await;
287            r
288        };
289        futures::future::try_join(task_a, task_b).await?;
290        anyhow::Result::<_>::Ok(())
291    }
292}
293
294impl AsyncRead for Console {
295    fn poll_read(
296        self: Pin<&mut Self>,
297        cx: &mut Context<'_>,
298        buf: &mut [u8],
299    ) -> std::task::Poll<std::io::Result<usize>> {
300        Pin::new(&mut self.get_mut().sys).poll_read(cx, buf)
301    }
302}
303
304impl AsyncWrite for Console {
305    fn poll_write(
306        self: Pin<&mut Self>,
307        cx: &mut Context<'_>,
308        buf: &[u8],
309    ) -> std::task::Poll<std::io::Result<usize>> {
310        Pin::new(&mut self.get_mut().sys).poll_write(cx, buf)
311    }
312
313    fn poll_flush(
314        self: Pin<&mut Self>,
315        cx: &mut Context<'_>,
316    ) -> std::task::Poll<std::io::Result<()>> {
317        Pin::new(&mut self.get_mut().sys).poll_flush(cx)
318    }
319
320    fn poll_close(
321        self: Pin<&mut Self>,
322        cx: &mut Context<'_>,
323    ) -> std::task::Poll<std::io::Result<()>> {
324        Pin::new(&mut self.get_mut().sys).poll_close(cx)
325    }
326}