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