refactor: resolve cross-crate integration TODOs and resources discovery
Cleanup pass over items collected in docs/rewrite/INTEGRATION_TODO.md: - OSC 133 printf-q / percent decoders live once in ghostty_vt::string_encoding; ghostty-os re-exports them. - CursorStyle and clipboard codepoint map entries have one definition (ghostty-terminal); ghostty-config now depends on ghostty-terminal. - ActionKey::docs() trailing blank lines fixed at the source; cli's workaround removed (+list-actions --docs still byte-identical). - x11_color exposes names in original case; cli no longer re-parses rgb.txt. - One cgroup scope wait loop (ghostty_os::cgroup::wait_for_scope). - Flatpak passwd lookup implemented in ghostty-os via a local pty. - +version shows the rustc version captured at build time. Resources: dev builds find <workspace>/resources automatically (keeping Ghostty's sentinel-file detection and GHOSTTY_RESOURCES_DIR); scripts/gen-terminfo.sh (just terminfo) regenerates the compiled terminfo; `just install [PREFIX]` installs binary, share/ghostty, terminfo, desktop/D-Bus/systemd/metainfo files and icons. Test Plan: - cargo test for vt, os, config, input, cli - diff `+list-keybinds`, `+list-actions --docs`, `+show-config --default` against /usr/bin/ghostty
This commit is contained in:
1 parent
062c643ea8
commit
7868e56273
24 files changed
+656
-513
No files matched your search
@@ -5,10 +5,11 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ghostty-vt.workspace = true
|
||||
gettext-rs = { version = "0.8.0", features = ["gettext-system"] }
|
||||
log = "0.4.34"
|
||||
nix = { version = "0.31.3", features = ["user"] }
|
||||
rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand"] }
|
||||
rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand", "pty"] }
|
||||
thiserror = "2.0.21"
|
||||
zbus = "5.19.0"
|
||||
|
||||
|
||||
@@ -24,6 +24,30 @@ pub fn current(pid: u32) -> Option<String> {
|
||||
Some(String::from_utf8_lossy(&rest[..end]).into_owned())
|
||||
}
|
||||
|
||||
/// True if the final path component of `pid`'s cgroup equals `expected`.
|
||||
pub fn scope_matches(pid: u32, expected: &str) -> bool {
|
||||
current(pid)
|
||||
.as_deref()
|
||||
.and_then(|raw| raw.rsplit_once('/'))
|
||||
.is_some_and(|(_, last)| last == expected)
|
||||
}
|
||||
|
||||
/// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`.
|
||||
/// Returns true if seen, false on timeout. This is the single wait loop
|
||||
/// shared by the termio `Command` hooks and apprt-gtk's pre-exec/post-fork.
|
||||
pub fn wait_for_scope(pid: u32, expected: &str) -> bool {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if start.elapsed() > std::time::Duration::from_millis(250) {
|
||||
return false;
|
||||
}
|
||||
if scope_matches(pid, expected) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -25,16 +25,12 @@ pub fn get() -> std::io::Result<Entry> {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO(integration): Zig shells out to the host via
|
||||
// FlatpakHostCommand + a pty (`getent passwd <name>`) when running in
|
||||
// Flatpak, because the sandbox passwd entry only has a correct username.
|
||||
// That needs the `ghostty-termio` Pty; until then we use the sandbox
|
||||
// entry and warn.
|
||||
// If we're in flatpak then our entry is always empty so we grab it
|
||||
// by shelling out to the host. note that we do HAVE an entry in the
|
||||
// sandbox but only the username is correct.
|
||||
if is_flatpak() {
|
||||
log::warn!(
|
||||
"flatpak detected, but host passwd lookup is not implemented; \
|
||||
using sandbox passwd entry"
|
||||
);
|
||||
log::info!("flatpak detected, will use host command to get our entry");
|
||||
return host_entry(&user.name);
|
||||
}
|
||||
|
||||
Ok(Entry {
|
||||
@@ -44,10 +40,99 @@ pub fn get() -> std::io::Result<Entry> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Flatpak: look up our entry on the host with `getent passwd <name>` over a
|
||||
/// pty (Zig `passwd.get`, flatpak branch).
|
||||
///
|
||||
/// Dependency note: Zig uses `pty.zig`; `ghostty-termio` (which owns the Rust
|
||||
/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened
|
||||
/// here with rustix instead. Like Zig, only `shell` and `home` are filled in.
|
||||
fn host_entry(name: &str) -> std::io::Result<Entry> {
|
||||
use rustix::fs::{Mode, OFlags};
|
||||
use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt};
|
||||
use std::io::Read;
|
||||
|
||||
let err = std::io::Error::from;
|
||||
let master =
|
||||
openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).map_err(err)?;
|
||||
grantpt(&master).map_err(err)?;
|
||||
unlockpt(&master).map_err(err)?;
|
||||
let slave_name = ptsname(&master, Vec::new()).map_err(err)?;
|
||||
let slave = rustix::fs::open(
|
||||
slave_name.as_c_str(),
|
||||
OFlags::RDWR | OFlags::NOCTTY,
|
||||
Mode::empty(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
|
||||
// Note: we wrap our getent call in a /bin/sh login shell because
|
||||
// some operating systems (NixOS tested) don't set the PATH for various
|
||||
// utilities properly until we get a login shell.
|
||||
let mut cmd = crate::flatpak::FlatpakHostCommand::new(
|
||||
vec![
|
||||
"/bin/sh".into(),
|
||||
"-l".into(),
|
||||
"-c".into(),
|
||||
format!("getent passwd {name}"),
|
||||
],
|
||||
slave.try_clone()?,
|
||||
slave.try_clone()?,
|
||||
slave.try_clone()?,
|
||||
);
|
||||
cmd.spawn().map_err(std::io::Error::other)?;
|
||||
cmd.wait().map_err(std::io::Error::other)?;
|
||||
|
||||
// Once started, we can close the child side. We do this after
|
||||
// wait right now but that is fine too. This lets us read the
|
||||
// parent and detect EOF.
|
||||
drop(cmd);
|
||||
drop(slave);
|
||||
|
||||
// Read all of our output (max total size is 1024 bytes, like Zig).
|
||||
let mut file = std::fs::File::from(master);
|
||||
let mut output = Vec::new();
|
||||
let mut buf = [0u8; 1024];
|
||||
loop {
|
||||
let n = match file.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
// EIO is triggered at the end since we closed our child side.
|
||||
// This is just EOF for this.
|
||||
Err(e) if e.raw_os_error() == Some(rustix::io::Errno::IO.raw_os_error()) => break,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
output.extend_from_slice(&buf[..n]);
|
||||
if output.len() > buf.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(parse_getent(&String::from_utf8_lossy(&output)))
|
||||
}
|
||||
|
||||
/// Shell and home are the last two `:`-separated fields of the output.
|
||||
fn parse_getent(output: &str) -> Entry {
|
||||
let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':');
|
||||
let shell = it.next().map(str::to_owned);
|
||||
let home = it.next().map(str::to_owned);
|
||||
Entry {
|
||||
shell,
|
||||
home,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_getent_output() {
|
||||
let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n");
|
||||
assert_eq!(e.shell.as_deref(), Some("/bin/zsh"));
|
||||
assert_eq!(e.home.as_deref(), Some("/home/mitchell"));
|
||||
assert_eq!(e.name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_entry() {
|
||||
// We should be able to get an entry
|
||||
|
||||
@@ -64,6 +64,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
||||
let Some(mut exe) = exe else {
|
||||
return ResourcesDir::default();
|
||||
};
|
||||
let exe_start = exe;
|
||||
|
||||
// We have an exe path! Climb the tree looking for the terminfo
|
||||
// bundle as we expect it.
|
||||
@@ -91,9 +92,41 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
|
||||
};
|
||||
}
|
||||
|
||||
// Rust-specific: development builds run out of `<workspace>/target`, where
|
||||
// there is no installed `share/` tree. Fall back to the in-repo
|
||||
// `<workspace>/resources` directory (it carries the same sentinel,
|
||||
// `terminfo/x/xterm-ghostty`, see scripts/gen-terminfo.sh) if the binary
|
||||
// lives inside this workspace.
|
||||
if debug && let Some(dir) = dev_resources_dir(exe_start) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(dir),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
|
||||
ResourcesDir::default()
|
||||
}
|
||||
|
||||
/// The workspace root this crate was compiled in (`crates/ghostty-os/../..`).
|
||||
fn workspace_root() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
/// Returns `<workspace>/resources` if `exe` is inside the workspace this
|
||||
/// crate was built from and the directory contains our sentinel.
|
||||
fn dev_resources_dir(exe: &Path) -> Option<String> {
|
||||
let root = workspace_root().canonicalize().ok()?;
|
||||
let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
|
||||
if !exe.starts_with(&root) {
|
||||
return None;
|
||||
}
|
||||
let res = root.join("resources");
|
||||
SENTINELS
|
||||
.iter()
|
||||
.any(|s| res.join(s).exists())
|
||||
.then(|| res.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Little helper to check if the "base/sub/suffix" directory exists and
|
||||
/// if so return "base/sub". The "suffix" is just used as a way to verify a
|
||||
/// directory seems roughly right.
|
||||
@@ -168,6 +201,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_fallback_finds_workspace_resources() {
|
||||
let exe = workspace_root().join("target/debug/ghostty");
|
||||
let r = resources_dir_impl(true, Some(&exe), None);
|
||||
assert!(r.app().is_some_and(|d| d.ends_with("/resources")));
|
||||
// Outside the workspace there is no fallback.
|
||||
let r = resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), None);
|
||||
assert_eq!(r.app(), None);
|
||||
// Release builds never use it.
|
||||
let r = resources_dir_impl(false, Some(&exe), None);
|
||||
assert_eq!(r.app(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_dir_checks_suffix() {
|
||||
let td = make_tree();
|
||||
|
||||
@@ -1,245 +1,8 @@
|
||||
use std::io::{self, Write};
|
||||
//! String decoding/encoding helpers (ZIG/src/os/string_encoding.zig).
|
||||
//!
|
||||
//! The implementation lives in `ghostty-vt` (the OSC 133 parser needs it and
|
||||
//! vt cannot depend on os); this module re-exports it.
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("decode error")]
|
||||
DecodeError,
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
impl PartialEq for Error {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
matches!((self, other), (Error::DecodeError, Error::DecodeError))
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode date from the buffer that has been encoded in the same way that
|
||||
/// `bash`'s `printf %q` encodes a string and write it to the writer. If an
|
||||
/// error is returned garbage may have been written to the buffer.
|
||||
pub fn printf_q_decode<W: Write>(writer: &mut W, buf: &[u8]) -> Result<(), Error> {
|
||||
let data: &[u8] = if buf.starts_with(b"$'") {
|
||||
// Strip off `$''` quoting.
|
||||
if buf.len() < 3 || !buf.ends_with(b"'") {
|
||||
return Err(Error::DecodeError);
|
||||
}
|
||||
&buf[2..buf.len() - 1]
|
||||
} else if buf.starts_with(b"'") {
|
||||
// Strip off `''` quoting.
|
||||
if buf.len() < 2 || !buf.ends_with(b"'") {
|
||||
return Err(Error::DecodeError);
|
||||
}
|
||||
&buf[1..buf.len() - 1]
|
||||
} else {
|
||||
buf
|
||||
};
|
||||
|
||||
let mut src = 0;
|
||||
while src < data.len() {
|
||||
match data[src] {
|
||||
b'\\' => {
|
||||
if src + 1 >= data.len() {
|
||||
return Err(Error::DecodeError);
|
||||
}
|
||||
let out = match data[src + 1] {
|
||||
c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => c,
|
||||
b'e' => 0x1b,
|
||||
b'n' => b'\n',
|
||||
b'r' => b'\r',
|
||||
b't' => b'\t',
|
||||
b'v' => 0x0b,
|
||||
_ => return Err(Error::DecodeError),
|
||||
};
|
||||
writer.write_all(&[out])?;
|
||||
src += 2;
|
||||
}
|
||||
c => {
|
||||
writer.write_all(&[c])?;
|
||||
src += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decode data from the buffer that has been URL percent encoded and write
|
||||
/// it to the given buffer. If an error is returned the garbage may have been
|
||||
/// written to the writer.
|
||||
pub fn url_percent_decode<W: Write>(writer: &mut W, buf: &[u8]) -> Result<(), Error> {
|
||||
let mut src = 0;
|
||||
while src < buf.len() {
|
||||
match buf[src] {
|
||||
b'%' => {
|
||||
if src + 2 >= buf.len() {
|
||||
return Err(Error::DecodeError);
|
||||
}
|
||||
let (Some(hi), Some(lo)) = (hex(buf[src + 1]), hex(buf[src + 2])) else {
|
||||
return Err(Error::DecodeError);
|
||||
};
|
||||
writer.write_all(&[(hi << 4) | lo])?;
|
||||
src += 3;
|
||||
}
|
||||
c => {
|
||||
writer.write_all(&[c])?;
|
||||
src += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex(c: u8) -> Option<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Some(c - b'0'),
|
||||
b'a'..=b'f' => Some(c - b'a' + 10),
|
||||
b'A'..=b'F' => Some(c - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the given character valid in URI percent encoding?
|
||||
fn is_valid_char(c: u8) -> bool {
|
||||
match c {
|
||||
b' ' | b';' | b'=' => false,
|
||||
_ => (0x20..=0x7e).contains(&c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write data to the writer after URI percent encoding.
|
||||
pub fn url_percent_encode<W: Write>(writer: &mut W, data: &[u8]) -> io::Result<()> {
|
||||
for &c in data {
|
||||
if is_valid_char(c) {
|
||||
writer.write_all(&[c])?;
|
||||
} else {
|
||||
write!(writer, "%{c:02X}")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pq(s: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
let mut w = Vec::new();
|
||||
printf_q_decode(&mut w, s).map(|()| w)
|
||||
}
|
||||
|
||||
fn pd(s: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
let mut w = Vec::new();
|
||||
url_percent_decode(&mut w, s).map(|()| w)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_1() {
|
||||
assert_eq!(pq(b"bobr\\ kurwa").unwrap(), b"bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_2() {
|
||||
assert_eq!(pq(b"bobr\\nkurwa").unwrap(), b"bobr\nkurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_3() {
|
||||
assert_eq!(pq(b"bobr\\dkurwa"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_4() {
|
||||
assert_eq!(pq(b"bobr kurwa\\"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_5() {
|
||||
assert_eq!(pq(b"$'bobr kurwa'").unwrap(), b"bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_6() {
|
||||
assert_eq!(pq(b"'bobr kurwa'").unwrap(), b"bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_7() {
|
||||
assert_eq!(pq(b"$'bobr kurwa"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_8() {
|
||||
assert_eq!(pq(b"$'"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_9() {
|
||||
assert_eq!(pq(b"'bobr kurwa"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_10() {
|
||||
assert_eq!(pq(b"'"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_q_escapes() {
|
||||
assert_eq!(
|
||||
pq(b"\\e\\r\\t\\v\\\\\\\"\\'\\$").unwrap(),
|
||||
b"\x1b\r\t\x0b\\\"'$"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singles_percent() {
|
||||
for c in 0..255u32 {
|
||||
let lower = format!("%{c:02x}");
|
||||
let decoded = pd(lower.as_bytes()).unwrap();
|
||||
assert_eq!(decoded, [c as u8]);
|
||||
let upper = format!("%{c:02X}");
|
||||
let decoded = pd(upper.as_bytes()).unwrap();
|
||||
assert_eq!(decoded, [c as u8]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_1() {
|
||||
assert_eq!(pd(b"bobr%20kurwa").unwrap(), b"bobr kurwa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_2() {
|
||||
assert_eq!(pd(b"bobr%2kurwa"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_3() {
|
||||
assert_eq!(pd(b"bobr%kurwa"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_4() {
|
||||
assert_eq!(pd(b"bobr%%kurwa"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_5() {
|
||||
assert_eq!(pd(b"bobr%20kurwa%20").unwrap(), b"bobr kurwa ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_6() {
|
||||
assert_eq!(pd(b"bobr%20kurwa%2"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_7() {
|
||||
assert_eq!(pd(b"bobr%20kurwa%"), Err(Error::DecodeError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_encode() {
|
||||
let mut w = Vec::new();
|
||||
url_percent_encode(&mut w, b"a b;c=d\n\xc3\xa9~").unwrap();
|
||||
assert_eq!(String::from_utf8(w).unwrap(), "a%20b%3Bc%3Dd%0A%C3%A9~");
|
||||
}
|
||||
}
|
||||
pub use ghostty_vt::string_encoding::{
|
||||
Error, printf_q_decode, url_percent_decode, url_percent_encode,
|
||||
};
|
||||
Reference in new issue
Block a user