Skip to main content

lore/
console.rs

1//! The terminal device itself, independent of the inherited streams.
2
3use std::fs::{File, OpenOptions};
4use std::io::Write;
5
6use anyhow::{Context, Result};
7
8/// Opens the terminal this process is attached to.
9///
10/// Neither inherited stream is safe to write to. stdout belongs to the shell
11/// integration, which reads the chosen command from it, and a PSReadLine key
12/// handler hands the child a redirected stderr, so anything written there
13/// disappears into a pipe.
14pub fn device() -> Result<File> {
15    let path = if cfg!(windows) { "CONOUT$" } else { "/dev/tty" };
16
17    OpenOptions::new()
18        .read(true)
19        .write(true)
20        .open(path)
21        .with_context(|| format!("failed to open the terminal device ({path})"))
22}
23
24/// Puts a message in front of the user, wherever it can still be seen.
25///
26/// Falls back to stderr, which is better than nothing when there is no terminal
27/// at all, such as under a test harness or a redirect.
28pub fn report(message: &str) {
29    if let Ok(mut device) = device()
30        && device.write_all(message.as_bytes()).is_ok()
31    {
32        return;
33    }
34
35    eprint!("{message}");
36}
37
38/// Puts the controlling terminal on stdin when something else is there.
39///
40/// zsh runs a widget's commands with stdin on `/dev/null`. The terminal library
41/// then falls back to opening `/dev/tty`, and on macOS the kernel refuses to
42/// poll that device: the answer to the cursor position question is never seen,
43/// and the picker waits for it forever. The pseudo terminal's own path, such as
44/// `/dev/ttys003`, has no such problem, so it is found by session and dup'd
45/// onto stdin before the terminal library looks. Nothing here reads stdin for
46/// anything else.
47///
48/// Best effort: when the terminal cannot be found, the picker proceeds as it
49/// would have and whatever happens next is reported the usual way.
50#[cfg(target_os = "macos")]
51pub fn adopt_terminal_as_stdin() {
52    use std::os::fd::AsRawFd;
53
54    // SAFETY: isatty only inspects a descriptor number.
55    if unsafe { libc::isatty(libc::STDIN_FILENO) } == 1 {
56        return;
57    }
58
59    let Some(terminal) = controlling_terminal() else {
60        return;
61    };
62
63    // SAFETY: both descriptors are open and owned by this process; dup2 leaves
64    // `terminal` untouched, and the copy on stdin outlives it.
65    unsafe {
66        libc::dup2(terminal.as_raw_fd(), libc::STDIN_FILENO);
67    }
68}
69
70/// The pseudo terminal this process's session is attached to, if any.
71///
72/// Only `ttys*` devices are tried. Anything else under `/dev/tty*` is a
73/// serial or Bluetooth port, and opening one of those blocks waiting for a
74/// carrier that will never come.
75#[cfg(target_os = "macos")]
76fn controlling_terminal() -> Option<File> {
77    use std::os::fd::AsRawFd;
78    use std::os::unix::fs::OpenOptionsExt;
79
80    // SAFETY: getsid(0) asks about the calling process and cannot fail for it.
81    let session = unsafe { libc::getsid(0) };
82
83    std::fs::read_dir("/dev")
84        .ok()?
85        .flatten()
86        .filter(|entry| entry.file_name().to_string_lossy().starts_with("ttys"))
87        .find_map(|entry| {
88            let file = OpenOptions::new()
89                .read(true)
90                .write(true)
91                .custom_flags(libc::O_NOCTTY)
92                .open(entry.path())
93                .ok()?;
94
95            // SAFETY: the descriptor is open and owned by `file`.
96            let owner = unsafe { libc::tcgetsid(file.as_raw_fd()) };
97            (owner == session).then_some(file)
98        })
99}
100
101#[cfg(not(target_os = "macos"))]
102pub fn adopt_terminal_as_stdin() {}