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