diff --git a/Cargo.lock b/Cargo.lock index 4bd1871d..e0a39fb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1026,9 +1026,9 @@ dependencies = [ [[package]] name = "shpool-vterm" -version = "0.2.2" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe03f99c0b6f0479ed1464eee5af8a478b09f8a117e94c6112bcb92f4364b44d" +checksum = "92718159586908698ddd1d4d1f55ab31e99925bbf0b04f5bae333f18a7782382" dependencies = [ "anyhow", "bitvec", diff --git a/libshpool/Cargo.toml b/libshpool/Cargo.toml index 41eb3e81..553c428b 100644 --- a/libshpool/Cargo.toml +++ b/libshpool/Cargo.toml @@ -35,7 +35,7 @@ log = "0.4" # logging facade (not used directly, but required if we have tracing tracing = "0.1" # logging and performance monitoring facade rmp-serde = "1" # serialization for the control protocol shpool_vt100 = "0.1.3" # terminal emulation for the scrollback buffer -shpool-vterm = "0.2.2" # alt terminal emulation for the scrollback buffer +shpool-vterm = "0.2.7" # alt terminal emulation for the scrollback buffer shell-words = "1" # parsing the -c/--cmd argument motd = { version = "0.2.2", default-features = false, features = [] } # getting the message-of-the-day termini = "1.0.0" # terminfo database diff --git a/libshpool/src/daemon/server.rs b/libshpool/src/daemon/server.rs index b12456be..f37ac380 100644 --- a/libshpool/src/daemon/server.rs +++ b/libshpool/src/daemon/server.rs @@ -870,6 +870,7 @@ impl Server { status, shell_pid: session.child_pid, attachments: lifecycle_state.attachment.into_iter().collect(), + window_title: session.window_title.get(), }) }) .collect(); @@ -1220,6 +1221,7 @@ impl Server { supports_sentinels, }; let child_pid = session_inner.pty_master.child_pid().ok_or(anyhow!("no child pid"))?; + let window_title = Arc::new(shell::SessionWindowTitle::default()); session_inner.shell_to_client_join_h = Some(session_inner.spawn_shell_to_client(shell::ShellToClientArgs { conn_id, @@ -1240,6 +1242,7 @@ impl Server { heartbeat_ack: heartbeat_ack_tx, maybe_switch: maybe_switch_rx, child_exit_notifier: shell_to_client_child_exit_notifier, + window_title: Arc::clone(&window_title), })?); if let Some(ttl_secs) = header.ttl_secs { @@ -1256,6 +1259,7 @@ impl Server { child_exit_notifier, started_at: time::SystemTime::now(), lifecycle: shell::SessionLifecycle::default(), + window_title, inner: Arc::new(Mutex::new(session_inner)), }) } diff --git a/libshpool/src/daemon/shell.rs b/libshpool/src/daemon/shell.rs index 56aa3d26..3745bd97 100644 --- a/libshpool/src/daemon/shell.rs +++ b/libshpool/src/daemon/shell.rs @@ -106,11 +106,33 @@ pub struct SessionLifecycleState { pub attachment: Option, } +/// A session's current window title. Written by the shell->client thread +/// whenever the window title changes, and read by `handle_list`. Kept out of +/// `inner` for the same reason as [`SessionLifecycleState::attachment`]. +#[derive(Debug, Default)] +pub struct SessionWindowTitle { + window_title: Mutex>, +} + +impl SessionWindowTitle { + /// Record a new window title, replacing any non-UTF-8 bytes with U+FFFD. + pub fn set(&self, window_title: Option<&[u8]>) { + *self.window_title.lock() = + window_title.map(|window_title| String::from_utf8_lossy(window_title).into_owned()); + } + + /// Return a copy of the current window title. + pub fn get(&self) -> Option { + self.window_title.lock().clone() + } +} + /// Session represent a shell session #[derive(Debug)] pub struct Session { pub started_at: time::SystemTime, pub lifecycle: SessionLifecycle, + pub window_title: Arc, pub child_pid: libc::pid_t, pub child_exit_notifier: Arc, pub shell_to_client_ctl: Arc>, @@ -260,6 +282,7 @@ pub struct ShellToClientArgs { // true if the client is still live, false if it has hung up on us. pub heartbeat_ack: crossbeam_channel::Sender<(u64, bool)>, pub child_exit_notifier: Arc, + pub window_title: Arc, } impl SessionInner { @@ -291,6 +314,9 @@ impl SessionInner { let mut output_spool = session_restore::new(config, &args.tty_size, args.scrollback_lines, &name); + // The raw bytes last written to `args.window_title`, cached locally so checking + // for changes doesn't lock `args.window_title`. + let mut last_window_title: Option> = None; let mut buf: Vec = vec![0; consts::BUF_SIZE]; let mut poll_fds = [poll::PollFd::new( watchable_master.borrow_fd(), @@ -601,6 +627,11 @@ impl SessionInner { if has_seen_prompt_sentinel { output_spool.process(buf); + let window_title = output_spool.window_title(); + if window_title != last_window_title.as_deref() { + args.window_title.set(window_title); + last_window_title = window_title.map(<[u8]>::to_vec); + } } let mut reset_client_conn = false; diff --git a/libshpool/src/session_restore.rs b/libshpool/src/session_restore.rs index 94a24ea3..4e5f279d 100644 --- a/libshpool/src/session_restore.rs +++ b/libshpool/src/session_restore.rs @@ -56,6 +56,10 @@ pub trait SessionSpool { /// Process bytes from pty master. fn process(&mut self, bytes: &[u8]); + + /// The current window title, or None if it is unset or empty. The title is + /// not checked to be UTF-8. + fn window_title(&self) -> Option<&[u8]>; } /// A spool that doesn't do anything. @@ -69,6 +73,10 @@ impl SessionSpool for NullSpool { } fn process(&mut self, _: &[u8]) {} + + fn window_title(&self) -> Option<&[u8]> { + None + } } /// A spool that restores the last screenful of content using shpool_vt100. @@ -100,6 +108,11 @@ impl SessionSpool for Vt100Screen { fn process(&mut self, bytes: &[u8]) { self.parser.process(bytes) } + + fn window_title(&self) -> Option<&[u8]> { + Some(self.parser.screen().title().as_bytes()) + .filter(|window_title| !window_title.is_empty()) + } } /// A spool that restores the last n lines of content using shpool_vt100. @@ -130,6 +143,11 @@ impl SessionSpool for Vt100Lines { fn process(&mut self, bytes: &[u8]) { self.parser.process(bytes) } + + fn window_title(&self) -> Option<&[u8]> { + Some(self.parser.screen().title().as_bytes()) + .filter(|window_title| !window_title.is_empty()) + } } /// A spool that restores the last screenful of content using shpool-vterm. @@ -157,6 +175,10 @@ impl SessionSpool for Vterm { fn process(&mut self, bytes: &[u8]) { self.term.process(bytes); } + + fn window_title(&self) -> Option<&[u8]> { + self.term.title() + } } /// Creates a spool given a `mode`. diff --git a/shpool-protocol/src/lib.rs b/shpool-protocol/src/lib.rs index 0da25fc3..666d7bcf 100644 --- a/shpool-protocol/src/lib.rs +++ b/shpool-protocol/src/lib.rs @@ -294,6 +294,11 @@ pub struct Session { /// independently of `status`, so they can briefly disagree. #[serde(default)] pub attachments: Vec, + /// This session's window title. None if it is unset or empty, or if + /// `session_restore_mode` is `simple`. Non-UTF-8 bytes are replaced with + /// U+FFFD. + #[serde(default)] + pub window_title: Option, } /// A session's attachment. diff --git a/shpool/tests/list.rs b/shpool/tests/list.rs index d275ec68..bb689902 100644 --- a/shpool/tests/list.rs +++ b/shpool/tests/list.rs @@ -541,3 +541,75 @@ fn json_attachment_kill_removes_session() -> anyhow::Result<()> { Ok(()) } + +/// Start a daemon with `config` and attach a session named sh1 to it. +fn attach_sh1(config: &str) -> anyhow::Result<(support::daemon::Proc, support::attach::Proc)> { + let mut daemon_proc = support::daemon::Proc::new(config, DaemonArgs::default()) + .context("starting daemon proc")?; + let bidi_enter_w = daemon_proc.events.take().unwrap().waiter(["daemon-bidi-stream-enter"]); + let sess1 = daemon_proc.attach("sh1", Default::default())?; + daemon_proc.events = Some(bidi_enter_w.wait_final_event("daemon-bidi-stream-enter")?); + Ok((daemon_proc, sess1)) +} + +/// The window title `shpool list --json` reports for sh1. +fn sh1_window_title(daemon_proc: &mut support::daemon::Proc) -> anyhow::Result { + let sessions = list_sessions(daemon_proc)?; + let sh1 = + sessions.iter().find(|s| s["name"] == "sh1").ok_or_else(|| anyhow!("sh1 not found"))?; + Ok(sh1["window_title"].clone()) +} + +/// Have sh1's shell print `printf_arg`, and wait for `shpool list --json` to +/// report `want` as its window title. +fn assert_window_title_reported(config: &str, printf_arg: &str, want: &str) -> anyhow::Result<()> { + let (mut daemon_proc, mut sess1) = attach_sh1(config)?; + assert!( + sh1_window_title(&mut daemon_proc)?.is_null(), + "expected no window title before one is set" + ); + + sess1.run_cmd(&format!("printf '{printf_arg}'"))?; + support::wait_until(|| Ok(sh1_window_title(&mut daemon_proc)? == want)) + .with_context(|| format!("window title should be reported as {want:?}"))?; + + Ok(()) +} + +#[test] +#[timeout(30000)] +fn json_window_title_vt100() -> anyhow::Result<()> { + assert_window_title_reported("restore_screen.toml", r"\033]2;hello\007", "hello") +} + +#[test] +#[timeout(30000)] +fn json_window_title_vterm() -> anyhow::Result<()> { + assert_window_title_reported("vterm_screen.toml", r"\033]2;hello\007", "hello") +} + +#[test] +#[timeout(30000)] +fn json_window_title_invalid_utf8_replaced() -> anyhow::Result<()> { + // vterm keeps a window title that is not UTF-8, where vt100 ignores it, so this + // shows the replacement. + assert_window_title_reported("vterm_screen.toml", r"\033]2;caf\351\007", "caf\u{fffd}") +} + +#[test] +#[timeout(30000)] +fn json_window_title_null_in_simple_restore_mode() -> anyhow::Result<()> { + let (mut daemon_proc, mut sess1) = attach_sh1("norc.toml")?; + let mut lm1 = sess1.line_matcher()?; + + sess1.run_cmd(r"printf '\033]2;hello\007'; echo done")?; + // The daemon handles output in order, so once `done` arrives it has also seen + // the window title. + lm1.scan_until_re("done$")?; + assert!( + sh1_window_title(&mut daemon_proc)?.is_null(), + "simple restore mode keeps no window title" + ); + + Ok(()) +}