Files
shell-rs/src/cli/server.rs
T
XuzandClaude Opus 5.5 e09adeefb7 外部 CLI 新增 exec --terminal:在已打开的终端里执行命令
只允许登录一次的堡垒机(外部连接)上,exec 重新登录会被拒绝,另开通道又会结束会话。
--terminal 把命令打进这台主机在 ShellRS 里已打开的远程终端:先发 Ctrl-C,探测出
交互 shell(bash、dash、busybox ash)后输入命令行,经 OSC 标记取回输出、退出码和
执行上下文(--json 的 context)。不做忙碌检测,只拒绝全屏程序和同一终端已有一次运行。
外部连接上普通 exec 登录失败时,说明里提示改用 --terminal。

shells.yml 在 Docker 里对 bash 4.2–5.2、dash、busybox ash 跑真实终端测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:16:30 +08:00

962 lines
32 KiB
Rust

//! The app's end of the CLI socket. Listens for as long as the app runs and
//! answers each connection on a thread of its own; the work itself is the
//! backend's, so tests can put a fake behind the same server.
use std::{
collections::VecDeque,
io::{self, BufReader},
path::{Path, PathBuf},
sync::{
Arc, Mutex, RwLock,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc,
},
thread::JoinHandle,
time::Duration,
};
#[cfg(unix)]
use tokio::sync::watch;
use super::link::OpenLink;
use super::manage::{
CliChange, CredentialSecrets, HostSecrets, credential_details, credential_matches,
credential_not_found, credential_secrets, host_details, host_info, host_not_found,
host_secrets, with_saved_credential_secrets, with_saved_passwords,
};
use super::protocol::{
CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo,
PROTOCOL_VERSION, Reply, Request, TerminalContext, TransferCounters, TransferSummary,
VersionOnly, parse_json, read_frame, write_frame, write_json,
};
use crate::host::{Host, HostLogin, HostStore, matches_query};
use crate::i18n::t;
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
use crate::terminal::{
BusyReason, RunEvent, RunFailure, RunHandle, RunProgress, RunTiming, TerminalRun,
};
/// Does what a CLI request asks. Every method blocks: each request has a
/// thread of its own.
pub trait CliBackend: Send + Sync + 'static {
/// Run `command` on the host, passing its output on as it
/// comes; the remote exit code. When `output` fails, the caller is gone
/// and the command should be abandoned.
fn exec(
&self,
target: &CliTarget,
command: &str,
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
) -> Result<i32, CliError>;
/// Copy the local `source` to `destination` on the host, the way scp
/// does. When `progress` fails, the caller is gone and the transfer
/// should stop.
fn upload(
&self,
target: &CliTarget,
source: &Path,
destination: &str,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError>;
/// Copy `source` on the host to the local `destination`, the way scp
/// does.
fn download(
&self,
target: &CliTarget,
source: &str,
destination: &Path,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError>;
/// Bring the folder `destination` on the host in line with the local
/// folder `source`: what changed is copied, what did not is left alone,
/// and with `delete` what is not in `source` goes.
fn sync(
&self,
target: &CliTarget,
source: &Path,
destination: &str,
delete: bool,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError>;
/// Whether the keychain (or, for a host not saved, memory) holds this
/// entry. Only asked when one host or credential is shown.
fn is_saved(&self, secret: &SecretRef) -> bool;
}
/// A saved host as the CLI sees it, with the login it connects with
/// resolved when the target list was built.
#[derive(Clone, Debug)]
pub struct CliTarget {
info: HostInfo,
details: HostDetails,
secrets: HostSecrets,
host: Host,
login: HostLogin,
}
impl CliTarget {
fn of(host: &Host, store: &HostStore) -> Self {
Self {
info: host_info(host, store),
details: host_details(host, store),
secrets: host_secrets(host, store),
host: host.clone(),
login: store.login_of(host),
}
}
pub fn host(&self) -> &Host {
&self.host
}
/// How a request logs in to the host.
pub fn login(&self) -> &HostLogin {
&self.login
}
/// Every host in `store`, with its group path, then the ones connected
/// to without saving them. A request logs in to those afresh too, with
/// the password kept in memory; a bastion host may refuse a login it
/// gave out for one use.
pub fn all(store: &HostStore) -> Vec<Self> {
store
.hosts()
.iter()
.chain(store.temporary_hosts())
.map(|host| Self::of(host, store))
.collect()
}
/// The host tree's search, plus the group path and the ID.
fn matches(&self, query: &str) -> bool {
let needle = query.trim().to_lowercase();
matches_query(&self.host, query)
|| self
.info
.group
.as_ref()
.is_some_and(|group| group.to_lowercase().contains(&needle))
|| self.info.id.to_lowercase() == needle
}
}
/// A saved credential as the CLI sees it.
#[derive(Clone, Debug)]
struct CliCredential {
details: CredentialDetails,
secrets: CredentialSecrets,
}
/// How long a change may wait for the app to take it up. Only the wait in
/// line counts: once taken, the change is being made and its outcome is
/// waited for however long the keychain takes, or an agent would try
/// again and make it twice.
const QUEUE_TIMEOUT: Duration = if cfg!(test) {
Duration::from_millis(300)
} else {
Duration::from_secs(10)
};
/// How an `exec --terminal` run is paced; quick in the tests.
const RUN_TIMING: RunTiming = if cfg!(test) {
RunTiming {
settle: Duration::ZERO,
probe: Duration::from_millis(300),
start: Duration::from_millis(300),
tick: Duration::from_millis(20),
}
} else {
RunTiming::STANDARD
};
/// An `exec --terminal` run waiting for the app to find the host's
/// terminal.
struct PendingRun {
id: u64,
host: String,
run: RunHandle,
}
/// A change waiting for the app, with where its outcome goes.
struct PendingChange {
id: u64,
change: CliChange,
reply: ChangeReply,
}
/// Where the app sends the outcome of a change it took up. Dropping it
/// unsent tells the request it will not come.
pub struct ChangeReply(mpsc::Sender<Result<Reply, CliError>>);
impl ChangeReply {
pub fn send(self, outcome: Result<Reply, CliError>) {
let _ = self.0.send(outcome);
}
}
/// What the listener and the request threads share with the app.
struct Shared {
enabled: AtomicBool,
targets: RwLock<Vec<CliTarget>>,
credentials: RwLock<Vec<CliCredential>>,
backend: Arc<dyn CliBackend>,
/// Set when ShellRS was opened again while this one runs, with the
/// links it was opened with, until the app has come forward and opened
/// them.
activation: Mutex<Option<Vec<OpenLink>>>,
/// Changes to hosts and credentials, oldest first, until the app takes
/// them up: only the UI thread can change the store.
changes: Mutex<VecDeque<PendingChange>>,
/// `exec --terminal` runs, oldest first, until the app takes them up:
/// only the UI thread can reach a terminal.
runs: Mutex<VecDeque<PendingRun>>,
next_change: AtomicU64,
/// What kinds of command were served, for 匿名使用统计, until the app
/// takes them.
usage: Mutex<Vec<CliUse>>,
}
/// The kind of a `shellrs` command, as 匿名使用统计 counts them: nothing
/// of what it asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CliUse {
Exec,
ExecTerminal,
Upload,
Download,
Sync,
Hosts,
Credentials,
}
impl CliUse {
fn of(request: &Request) -> Option<Self> {
match request {
Request::Exec { .. } => Some(Self::Exec),
Request::ExecInTerminal { .. } => Some(Self::ExecTerminal),
Request::Upload { .. } => Some(Self::Upload),
Request::Download { .. } => Some(Self::Download),
Request::Sync { .. } => Some(Self::Sync),
Request::List { .. }
| Request::ShowHost { .. }
| Request::CreateHost { .. }
| Request::UpdateHost { .. }
| Request::DeleteHost { .. } => Some(Self::Hosts),
Request::ListCredentials { .. }
| Request::ShowCredential { .. }
| Request::CreateCredential { .. }
| Request::UpdateCredential { .. }
| Request::DeleteCredential { .. } => Some(Self::Credentials),
Request::Activate { .. } => None,
}
}
}
/// Listens on the CLI socket until dropped. Always listening, even with
/// 启用外部 CLI off, so a caller hears "not enabled" rather than "not
/// running" and can tell the user which of the two to fix.
pub struct CliServer {
shared: Arc<Shared>,
_listener: Listener,
}
impl CliServer {
/// Take over `endpoint` (from [`crate::app::cli_endpoint`]), which must
/// not be another running ShellRS's. Only the user running the app can
/// connect.
pub fn start(endpoint: PathBuf, backend: Arc<dyn CliBackend>) -> io::Result<Self> {
let shared = Arc::new(Shared {
enabled: AtomicBool::new(false),
targets: RwLock::new(Vec::new()),
credentials: RwLock::new(Vec::new()),
backend,
activation: Mutex::new(None),
changes: Mutex::new(VecDeque::new()),
runs: Mutex::new(VecDeque::new()),
next_change: AtomicU64::new(0),
usage: Mutex::new(Vec::new()),
});
let listener = listen(endpoint, shared.clone())?;
Ok(Self {
shared,
_listener: listener,
})
}
pub fn set_enabled(&self, enabled: bool) {
self.shared.enabled.store(enabled, Ordering::Release);
}
/// What the requests see of the hosts and credentials: `store` as it
/// is now. Called whenever it changes.
pub fn set_hosts(&self, store: &HostStore) {
let credentials = store
.credentials()
.iter()
.map(|credential| CliCredential {
details: credential_details(credential, store),
secrets: credential_secrets(credential),
})
.collect();
*self
.shared
.targets
.write()
.unwrap_or_else(|error| error.into_inner()) = CliTarget::all(store);
*self
.shared
.credentials
.write()
.unwrap_or_else(|error| error.into_inner()) = credentials;
}
/// The oldest change waiting, for the app to make and answer. One at a
/// time: the next is only taken once this one is answered.
pub fn take_change(&self) -> Option<(CliChange, ChangeReply)> {
self.shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner())
.pop_front()
.map(|pending| (pending.change, pending.reply))
}
/// The oldest `exec --terminal` run waiting, with the host it is for,
/// for the app to hand to that host's terminal, or say why it cannot.
pub fn take_run(&self) -> Option<(String, RunHandle)> {
self.shared
.runs
.lock()
.unwrap_or_else(|error| error.into_inner())
.pop_front()
.map(|pending| (pending.host, pending.run))
}
/// Whether ShellRS was opened again since this was last asked, and the
/// links it was opened with, oldest first: `Some` and empty for a plain
/// re-open. The request threads cannot reach the window, so the app asks
/// here on a timer, then comes forward and opens them.
pub fn take_activation(&self) -> Option<Vec<OpenLink>> {
self.shared
.activation
.lock()
.unwrap_or_else(|error| error.into_inner())
.take()
}
/// The kinds of command served since this was last asked, oldest first.
/// Asked on the same timer as `take_activation`.
pub fn take_usage(&self) -> Vec<CliUse> {
std::mem::take(
&mut *self
.shared
.usage
.lock()
.unwrap_or_else(|error| error.into_inner()),
)
}
}
impl Drop for CliServer {
/// The app is going: whatever waits for it to take up a change or a
/// run hears that it never will.
fn drop(&mut self) {
self.shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
for pending in self
.shared
.runs
.lock()
.unwrap_or_else(|error| error.into_inner())
.drain(..)
{
pending.run.fail(RunFailure::NotConnected);
}
}
}
/// The listening thread, stopped when dropped. On Unix, the socket file
/// goes with it.
#[cfg(unix)]
struct Listener {
socket: PathBuf,
shutdown: watch::Sender<bool>,
thread: Option<JoinHandle<()>>,
}
#[cfg(unix)]
impl Drop for Listener {
fn drop(&mut self) {
self.shutdown.send_replace(true);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
let _ = std::fs::remove_file(&self.socket);
}
}
/// The socket is the user's alone, and every caller's user id is checked
/// again.
#[cfg(unix)]
fn listen(socket: PathBuf, shared: Arc<Shared>) -> io::Result<Listener> {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
use std::os::unix::net::{UnixListener, UnixStream};
if std::fs::symlink_metadata(&socket).is_ok() {
if UnixStream::connect(&socket).is_ok() {
return Err(another_app());
}
// Left behind by a ShellRS that did not get to clean up.
std::fs::remove_file(&socket)?;
}
if let Some(dir) = socket.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = UnixListener::bind(&socket)?;
std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o600))?;
let owner = std::fs::metadata(&socket)?.uid();
listener.set_nonblocking(true)?;
let (shutdown, mut stop) = watch::channel(false);
let thread = std::thread::Builder::new()
.name("shellrs-cli".into())
.spawn(move || {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return;
};
runtime.block_on(async move {
let Ok(listener) = tokio::net::UnixListener::from_std(listener) else {
return;
};
loop {
tokio::select! {
accepted = listener.accept() => {
if let Ok((stream, _)) = accepted {
accept(stream, owner, &shared);
}
}
_ = stop.changed() => break,
}
}
});
})?;
Ok(Listener {
socket,
shutdown,
thread: Some(thread),
})
}
/// Hand a connection from the same user to a thread of its own.
#[cfg(unix)]
fn accept(stream: tokio::net::UnixStream, owner: u32, shared: &Arc<Shared>) {
if !stream
.peer_cred()
.is_ok_and(|credentials| credentials.uid() == owner)
{
return;
}
let Ok(stream) = stream.into_std() else {
return;
};
if stream.set_nonblocking(false).is_err() {
return;
}
let shared = shared.clone();
let _ = std::thread::Builder::new()
.name("shellrs-cli-request".into())
.spawn(move || {
// A caller that hangs up mid-answer is no one's problem.
let _ = serve(&mut BufReader::new(&stream), &mut &stream, &shared);
});
}
/// The listening thread, stopped when dropped.
#[cfg(windows)]
struct Listener {
pipe: PathBuf,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
#[cfg(windows)]
impl Drop for Listener {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
let Some(thread) = self.thread.take() else {
return;
};
// The thread waits in ConnectNamedPipe; a caller wakes it. Until it
// has gone: between two pipe instances there is nothing to connect
// to, so one knock may not be heard.
while !thread.is_finished() {
let _ = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&self.pipe);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let _ = thread.join();
}
}
/// A named pipe only the current user can open, and only from this
/// machine. Its name is the machine's, not a file of the user's, so it is
/// created as the first instance: if anyone already holds it, the server
/// does not start.
#[cfg(windows)]
fn listen(pipe: PathBuf, shared: Arc<Shared>) -> io::Result<Listener> {
use super::pipe_windows;
let first = pipe_windows::create_instance(&pipe, true).map_err(|error| {
if pipe_windows::is_taken(&error) {
another_app()
} else {
error
}
})?;
let stop = Arc::new(AtomicBool::new(false));
let thread = std::thread::Builder::new()
.name("shellrs-cli".into())
.spawn({
let (pipe, stop) = (pipe.clone(), stop.clone());
move || {
let mut waiting = first;
loop {
let connected = pipe_windows::wait_for_client(&waiting);
if stop.load(Ordering::Acquire) {
break;
}
// The next instance first, so a caller arriving now
// finds the pipe busy rather than gone.
let next = loop {
match pipe_windows::create_instance(&pipe, false) {
Ok(next) => break Some(next),
Err(_) if !stop.load(Ordering::Acquire) => {
std::thread::sleep(std::time::Duration::from_millis(100))
}
Err(_) => break None,
}
};
let Some(next) = next else {
break;
};
let current = std::mem::replace(&mut waiting, next);
// Otherwise the caller came and went already.
if connected.is_ok() {
answer(std::fs::File::from(current), &shared);
}
}
}
})?;
Ok(Listener {
pipe,
stop,
thread: Some(thread),
})
}
/// Answer a connected caller on a thread of its own. Closing the pipe
/// afterwards keeps what the caller has not read yet: only
/// `DisconnectNamedPipe` would throw it away.
#[cfg(windows)]
fn answer(pipe: std::fs::File, shared: &Arc<Shared>) {
let shared = shared.clone();
let _ = std::thread::Builder::new()
.name("shellrs-cli-request".into())
.spawn(move || {
// A caller that hangs up mid-answer is no one's problem.
let _ = serve(&mut BufReader::new(&pipe), &mut &pipe, &shared);
});
}
#[cfg(not(any(unix, windows)))]
struct Listener;
#[cfg(not(any(unix, windows)))]
fn listen(_: PathBuf, _: Arc<Shared>) -> io::Result<Listener> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
t!("cli.unsupported").to_string(),
))
}
fn another_app() -> io::Error {
io::Error::new(
io::ErrorKind::AddrInUse,
t!("cli.server.another_app").to_string(),
)
}
/// Read one request and answer it.
fn serve(
reader: &mut impl io::Read,
writer: &mut impl io::Write,
shared: &Shared,
) -> io::Result<()> {
let Some((FrameKind::Json, payload)) = read_frame(reader)? else {
return Ok(());
};
let reply = respond(&payload, writer, shared).unwrap_or_else(|error| Reply::Error {
code: error.code,
message: error.message,
});
write_json(writer, &reply)
}
/// Carry out one request. Output and progress go to `writer` as they come;
/// what comes back is the reply that ends the connection, or the error sent
/// in its place.
fn respond(
payload: &[u8],
writer: &mut impl io::Write,
shared: &Shared,
) -> Result<Reply, CliError> {
let bad_request = |error: io::Error| CliError::new(ErrorCode::BadRequest, error.to_string());
let envelope = parse_json::<Envelope>(payload);
// Before the checks below: coming forward means the same in every
// version, and it is ShellRS being opened again, not the external CLI.
if let Ok(Envelope {
request: Request::Activate { open },
..
}) = &envelope
{
shared
.activation
.lock()
.unwrap_or_else(|error| error.into_inner())
.get_or_insert_with(Vec::new)
.extend(open.clone());
return Ok(Reply::Activated);
}
// The version before the request, which another version may spell in
// a way this one cannot read.
let version = parse_json::<VersionOnly>(payload).map_err(bad_request)?;
if version.version != PROTOCOL_VERSION {
return Err(CliError::new(
ErrorCode::VersionMismatch,
t!("cli.server.version_mismatch"),
));
}
let envelope = envelope.map_err(bad_request)?;
if !shared.enabled.load(Ordering::Acquire) {
return Err(CliError::new(
ErrorCode::NotEnabled,
t!("cli.server.not_enabled"),
));
}
if let Some(usage) = CliUse::of(&envelope.request) {
shared
.usage
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(usage);
}
match envelope.request {
Request::List { query } => {
let targets = shared
.targets
.read()
.unwrap_or_else(|error| error.into_inner());
let hosts = targets
.iter()
.filter(|target| query.as_deref().is_none_or(|query| target.matches(query)))
.map(|target| target.info.clone())
.collect();
Ok(Reply::Hosts { hosts })
}
Request::Exec { host, command } => {
let target = find(shared, &host)?;
let code = shared
.backend
.exec(&target, &command, &mut |stream, bytes| {
let kind = match stream {
ExecStream::Stdout => FrameKind::Stdout,
ExecStream::Stderr => FrameKind::Stderr,
};
write_frame(writer, kind, bytes)
})?;
Ok(Reply::Exit { code })
}
Request::ExecInTerminal { host, command } => {
let target = find(shared, &host)?;
run_in_terminal(shared, writer, &target.info.id, command)
}
Request::Upload {
host,
source,
destination,
} => {
let target = find(shared, &host)?;
if !source.is_absolute() {
return Err(not_absolute(&source));
}
shared
.backend
.upload(&target, &source, &destination, &mut |counters| {
write_json(writer, &Reply::Progress(counters))
})
.map(Reply::TransferDone)
}
Request::Download {
host,
source,
destination,
} => {
let target = find(shared, &host)?;
if !destination.is_absolute() {
return Err(not_absolute(&destination));
}
shared
.backend
.download(&target, &source, &destination, &mut |counters| {
write_json(writer, &Reply::Progress(counters))
})
.map(Reply::TransferDone)
}
Request::Sync {
host,
source,
destination,
delete,
} => {
let target = find(shared, &host)?;
if !source.is_absolute() {
return Err(not_absolute(&source));
}
shared
.backend
.sync(&target, &source, &destination, delete, &mut |counters| {
write_json(writer, &Reply::Progress(counters))
})
.map(Reply::TransferDone)
}
Request::ShowHost { host } => {
let target = find(shared, &host)?;
let saved = |secret: &SecretRef| shared.backend.is_saved(secret);
Ok(Reply::Host(with_saved_passwords(
target.details,
&target.secrets,
saved,
)))
}
Request::ListCredentials { query } => {
let credentials = shared
.credentials
.read()
.unwrap_or_else(|error| error.into_inner())
.iter()
.filter(|credential| {
query
.as_deref()
.is_none_or(|query| credential_matches(&credential.details, query))
})
.map(|credential| credential.details.clone())
.collect();
Ok(Reply::Credentials { credentials })
}
Request::ShowCredential { credential } => {
let found = shared
.credentials
.read()
.unwrap_or_else(|error| error.into_inner())
.iter()
.find(|known| known.details.id == credential.trim())
.cloned()
.ok_or_else(|| credential_not_found(&credential))?;
let saved = |secret: &SecretRef| shared.backend.is_saved(secret);
Ok(Reply::Credential(with_saved_credential_secrets(
found.details,
&found.secrets,
saved,
)))
}
Request::CreateHost { fields } => change(shared, CliChange::CreateHost(fields)),
Request::UpdateHost { host, fields } => {
change(shared, CliChange::UpdateHost { host, fields })
}
Request::DeleteHost { host, force } => {
change(shared, CliChange::DeleteHost { host, force })
}
Request::CreateCredential { fields } => change(shared, CliChange::CreateCredential(fields)),
Request::UpdateCredential { credential, fields } => {
change(shared, CliChange::UpdateCredential { credential, fields })
}
Request::DeleteCredential { credential } => {
change(shared, CliChange::DeleteCredential { credential })
}
// Answered above, before anything was checked.
Request::Activate { .. } => Ok(Reply::Activated),
}
}
/// Hand a change to the app and wait for its outcome. Taken back, so
/// certainly not made, when the app does not take it up in time.
fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
let gone = || {
CliError::new(
ErrorCode::ConnectFailed,
t!("cli.server.closed_during_change"),
)
};
let (sender, outcome) = mpsc::channel();
let id = shared.next_change.fetch_add(1, Ordering::Relaxed);
shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner())
.push_back(PendingChange {
id,
change,
reply: ChangeReply(sender),
});
match outcome.recv_timeout(QUEUE_TIMEOUT) {
Ok(outcome) => return outcome,
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(gone()),
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
{
let mut changes = shared
.changes
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(index) = changes.iter().position(|pending| pending.id == id) {
changes.remove(index);
return Err(CliError::new(
ErrorCode::ConnectFailed,
t!("cli.server.change_timed_out"),
));
}
}
// Taken up just now: being made.
outcome.recv().unwrap_or_else(|_| Err(gone()))
}
/// Hand a run to the app, which finds the host's terminal, and see it
/// through: the command's output as stdout, its context, its exit code.
/// Taken back, so certainly not typed, when the app does not take it up
/// in time. While the command prints nothing, an empty frame now and then
/// finds out whether the caller is still there; once it is not, the
/// command gets a ^C.
fn run_in_terminal(
shared: &Shared,
writer: &mut impl io::Write,
host: &str,
command: String,
) -> Result<Reply, CliError> {
let (run, events) = TerminalRun::new(command);
let id = shared.next_change.fetch_add(1, Ordering::Relaxed);
shared
.runs
.lock()
.unwrap_or_else(|error| error.into_inner())
.push_back(PendingRun {
id,
host: host.to_owned(),
run: run.handle(),
});
let first = match events.recv_timeout(QUEUE_TIMEOUT) {
Ok(event) => event,
Err(_) => {
let mut runs = shared
.runs
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(index) = runs.iter().position(|pending| pending.id == id) {
runs.remove(index);
return Err(CliError::new(
ErrorCode::ConnectFailed,
t!("cli.terminal.timed_out"),
));
}
drop(runs);
// Taken up just now: its answer is on the way.
events
.recv()
.map_err(|_| run_error(RunFailure::NotConnected, host))?
}
};
if let RunEvent::Failed(failure) = first {
return Err(run_error(failure, host));
}
let code = run
.drive(&events, RUN_TIMING, &mut |progress| match progress {
RunProgress::Context(context) => write_json(
writer,
&Reply::Context(TerminalContext {
user: context.user,
host: context.host,
cwd: context.cwd,
shell: context.shell,
interp: context.interp,
}),
),
RunProgress::Output(bytes) => write_frame(writer, FrameKind::Stdout, &bytes),
RunProgress::Idle => write_frame(writer, FrameKind::Stdout, &[]),
})
.map_err(|failure| run_error(failure, host))?;
Ok(Reply::Exit { code })
}
/// Why a run did not run, in the CLI's words.
fn run_error(failure: RunFailure, host: &str) -> CliError {
let busy = |message| CliError::new(ErrorCode::TerminalBusy, message);
match failure {
RunFailure::HostNotFound => host_not_found(host),
RunFailure::NoTerminal => {
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.no_terminal"))
}
RunFailure::NotConnected => {
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.not_connected"))
}
RunFailure::UnsupportedShell(shell) if shell.is_empty() => CliError::new(
ErrorCode::UnsupportedShell,
t!("cli.terminal.unknown_shell"),
),
RunFailure::UnsupportedShell(shell) => CliError::new(
ErrorCode::UnsupportedShell,
t!("cli.terminal.unsupported_shell", shell = shell),
),
RunFailure::TooLong { limit } => CliError::new(
ErrorCode::TooLong,
t!("cli.terminal.too_long", limit = limit),
),
RunFailure::Busy(BusyReason::FullScreen) => busy(t!("cli.terminal.full_screen")),
RunFailure::Busy(BusyReason::AnotherRun) => busy(t!("cli.terminal.another_run")),
RunFailure::Busy(BusyReason::NoAnswer) => busy(t!("cli.terminal.no_answer")),
RunFailure::Busy(BusyReason::NotStarted) => busy(t!("cli.terminal.not_started")),
RunFailure::Disconnected => {
CliError::new(ErrorCode::ConnectFailed, t!("cli.terminal.disconnected"))
}
}
}
fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
shared
.targets
.read()
.unwrap_or_else(|error| error.into_inner())
.iter()
.find(|target| target.info.id == id.trim())
.cloned()
.ok_or_else(|| host_not_found(id))
}
fn not_absolute(path: &Path) -> CliError {
CliError::new(
ErrorCode::BadRequest,
t!("cli.server.not_absolute", path = path.display()),
)
}