179 lines
6.2 KiB
Rust
179 lines
6.2 KiB
Rust
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
|
|
|
use alacritty_terminal::{
|
|
event::{Event, EventListener, WindowSize},
|
|
event_loop::{EventLoop, Notifier},
|
|
sync::FairMutex,
|
|
term::{Config, Term, test::TermSize},
|
|
tty::{self, Options, Shell},
|
|
};
|
|
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
|
|
|
/// Hands a terminal's events to its view.
|
|
#[derive(Clone)]
|
|
pub(crate) struct Listener(UnboundedSender<Event>);
|
|
|
|
impl EventListener for Listener {
|
|
fn send_event(&self, event: Event) {
|
|
if self.0.unbounded_send(event).is_err() {
|
|
log::debug!("terminal: an event after its view closed");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a terminal runs: a program and its arguments, the login shell when none; where; and variables on top of TERM, COLORTERM, and LANG when the app has none.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Launch {
|
|
pub program: Option<(String, Vec<String>)>,
|
|
pub cwd: Option<PathBuf>,
|
|
pub env: Vec<(String, String)>,
|
|
}
|
|
|
|
/// A grid, the channel of its events, and the pseudo-terminal that feeds it when one does.
|
|
pub(crate) struct Session {
|
|
pub term: Arc<FairMutex<Term<Listener>>>,
|
|
pub pty: Option<Notifier>,
|
|
pub events: UnboundedReceiver<Event>,
|
|
}
|
|
|
|
/// A grid no process feeds, for replayed output.
|
|
pub(crate) fn detached(size: TermSize) -> Session {
|
|
let (sender, events) = unbounded();
|
|
let term = Term::new(Config::default(), &size, Listener(sender));
|
|
Session {
|
|
term: Arc::new(FairMutex::new(term)),
|
|
pty: None,
|
|
events,
|
|
}
|
|
}
|
|
|
|
/// Starts `launch` on a pseudo-terminal of `size` cells, each `cell` pixels.
|
|
pub(crate) fn spawn(launch: &Launch, size: TermSize, cell: (u16, u16)) -> anyhow::Result<Session> {
|
|
if let Some(cwd) = &launch.cwd {
|
|
anyhow::ensure!(
|
|
cwd.is_dir(),
|
|
"a terminal cannot start in {}: no such folder",
|
|
cwd.display()
|
|
);
|
|
#[cfg(unix)]
|
|
rustix::fs::access(cwd, rustix::fs::Access::EXEC_OK).map_err(|error| {
|
|
anyhow::anyhow!("a terminal cannot enter {}: {error}", cwd.display())
|
|
})?;
|
|
}
|
|
let (sender, events) = unbounded();
|
|
let listener = Listener(sender);
|
|
let term = Arc::new(FairMutex::new(Term::new(
|
|
Config::default(),
|
|
&size,
|
|
listener.clone(),
|
|
)));
|
|
let mut env: HashMap<String, String> = HashMap::from([
|
|
("TERM".to_string(), "xterm-256color".to_string()),
|
|
("COLORTERM".to_string(), "truecolor".to_string()),
|
|
]);
|
|
if std::env::var_os("LANG").is_none() {
|
|
env.insert("LANG".into(), "en_US.UTF-8".into());
|
|
}
|
|
env.extend(launch.env.iter().cloned());
|
|
let options = Options {
|
|
shell: launch
|
|
.program
|
|
.clone()
|
|
.map(|(program, args)| Shell::new(program, args)),
|
|
working_directory: launch.cwd.clone(),
|
|
drain_on_exit: true,
|
|
env,
|
|
};
|
|
let pty = tty::new(&options, window_size(&size, cell), 0)?;
|
|
let event_loop = EventLoop::new(term.clone(), listener, pty, true, false)?;
|
|
let pty = Notifier(event_loop.channel());
|
|
event_loop.spawn();
|
|
log::info!("terminal: started {:?}", launch.program);
|
|
Ok(Session {
|
|
term,
|
|
pty: Some(pty),
|
|
events,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn window_size(size: &TermSize, cell: (u16, u16)) -> WindowSize {
|
|
let cells =
|
|
|count: usize| u16::try_from(count).expect("a terminal is under 65536 cells a side");
|
|
WindowSize {
|
|
num_lines: cells(size.screen_lines),
|
|
num_cols: cells(size.columns),
|
|
cell_width: cell.0,
|
|
cell_height: cell.1,
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
mod tests {
|
|
use alacritty_terminal::index::{Column, Line};
|
|
use futures::StreamExt;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_folder_that_cannot_be_entered_stops_the_start() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let folder = std::env::temp_dir().join(format!("ely-shut-{}", std::process::id()));
|
|
std::fs::create_dir_all(&folder).expect("a temp folder");
|
|
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600))
|
|
.expect("mode 600");
|
|
let launch = Launch {
|
|
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
|
cwd: Some(folder.clone()),
|
|
..Launch::default()
|
|
};
|
|
let started = spawn(&launch, TermSize::new(20, 4), (8, 16));
|
|
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700))
|
|
.expect("mode 700");
|
|
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
|
let error = started.err().expect("a folder it cannot enter is an error");
|
|
assert!(error.to_string().contains("ely-shut"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_folder_stops_the_start() {
|
|
let launch = Launch {
|
|
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
|
cwd: Some("/nonexistent/ely".into()),
|
|
..Launch::default()
|
|
};
|
|
let error = spawn(&launch, TermSize::new(20, 4), (8, 16))
|
|
.err()
|
|
.expect("a missing folder is an error");
|
|
assert!(error.to_string().contains("/nonexistent/ely"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_program_prints_into_the_grid_through_a_pseudo_terminal() {
|
|
let launch = Launch {
|
|
program: Some((
|
|
"/bin/sh".into(),
|
|
vec!["-c".into(), "printf 'ely %s' \"$ELY\"".into()],
|
|
)),
|
|
env: vec![("ELY".into(), "ok".into())],
|
|
..Launch::default()
|
|
};
|
|
let mut session = spawn(&launch, TermSize::new(20, 4), (8, 16)).expect("sh starts");
|
|
let status = futures::executor::block_on(async {
|
|
let mut status = None;
|
|
while let Some(event) = session.events.next().await {
|
|
match event {
|
|
Event::ChildExit(ended) => status = Some(ended),
|
|
Event::Exit => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
status
|
|
});
|
|
assert!(status.expect("the program ended").success());
|
|
let term = session.term.lock();
|
|
let row = &term.grid()[Line(0)];
|
|
let text: String = (0..6).map(|column| row[Column(column)].c).collect();
|
|
assert_eq!(text, "ely ok");
|
|
}
|
|
}
|