T29: the foreground is its group and the executable its leader runs, so exec herdr from a shell is followed and a program that did not read is tried again; a relative herdr socket lies in the client's folder; on Linux an executable an update replaced still names itself herdr
This commit is contained in:
4 files changed
+119
-28
No files matched your search
@@ -92,4 +92,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-15: a release's macOS disk image comes from the Release workflow, dispatched once the draft holds the Windows and Linux builds. It runs on `macos-signing`, the Mac mini's runner in the owner's login session, whose keychain holds the Developer ID identity; at the owner's choice that runner moved from AgentCompany alone to the owner's level, so every repository reaches it. The job reads the App Store Connect key from the owner's user-level secrets `APPLE_API_ISSUER`, `APPLE_API_KEY_ID` and `APPLE_API_KEY_P8`, writes the .p8 into the runner's temp folder for notarytool alone, and puts the stapled image on the draft through `scripts/release-asset.sh` with the job's own token. One notarization covers the image and the app inside it; the image carries the ticket, and the app copied out finds its own online on its first open.
|
||||
- 2026-10-15: on Omarchy 4 (Linux) ELYTerminal follows the system's theme. Omarchy renders the current theme's `colors.toml` through its own resolver into `~/.local/state/omarchy/current/theme/ghostty.conf`, under `$HOME` whatever `XDG_STATE_HOME` says, so ELYTerminal reads that file and wears what Ghostty wears there: its color lines go through the strict parser the bundled themes use, and the rest, such as the glass block, pass by. Omarchy writes no `cursor-text`, so the background stands in, as in Ghostty and Omarchy's kitty template. ELYTerminal reads the file twice a second, since Omarchy signals only Alacritty, kitty and Ghostty, by name, and wears it whenever its text changes and parses; a check that fails is logged once while it lasts and tried again, so a file made readable again is worn though nothing else about it changed. The Omarchy theme is the default unless that file is surely absent, so a folder that cannot be read makes a fresh install wear it and the start name the error; another pick stops following. At start a missing file means no Omarchy unless the settings wear it, a file that cannot be read stops the start, and one that does not parse stops it only when worn, else it is logged and no Omarchy card shows. While running, a missing file is a switch in progress, and any other failure keeps the last theme. Omarchy 3's `~/.config/omarchy/current` gets no shim.
|
||||
- 2026-10-16: on Hyprland 0.56 a window blurs only while Hyprland's own `decoration:blur:enabled` is on: its `shouldBlur` reads that option first, then the window's `no_blur`, RGBX and opaque rules, then the blur region a surface sets through `ext_background_effect_v1`, which Hyprland offers with the blur capability whatever the option says. Omarchy 4.0.4's default `looknfeel.lua` turns the option off, so on Omarchy no window blurs until the user turns it on; ELYTerminal leaves the compositor's settings alone. On the owner's Omarchy the log named `ext_background_effect_v1` and the pane's region, and the wallpaper behind the window blurred with the option on and showed sharp with it off.
|
||||
- 2026-10-16: herdr 0.9 keeps its panes' OSC 7 and writes only a title, notifications, OSC 52 and bells to the terminal it runs in, so no terminal hears a folder from inside it; herdr's server knows each pane's `cwd`, the folder its shell reported, else the shell's own. On macOS and Linux each session asks twice a second which process group leads its pty's foreground (`tcgetpgrp` on a copy of the master). When that leader's executable is `herdr` and its arguments start herdr's terminal client (no command, `session attach NAME` or `client`), ELYTerminal reads the client's arguments and variables (`/proc` on Linux; `KERN_PROCARGS2` on macOS, which hides only the variables of Apple's own programs) and finds its socket as herdr 0.9 does: `--session NAME`, then `HERDR_SOCKET_PATH`, then `HERDR_SESSION`, under `$XDG_CONFIG_HOME/herdr`, else `~/.config/herdr`. It asks that socket for `session.snapshot` twice a second, a connection each, since herdr closes one after its reply, and reports the focused pane's `cwd` as the session's folder when it changes and again each time herdr takes the foreground. Focus belongs to herdr's server, so clients of one server follow one focus; a client of another machine's server (`--remote`, `--machine`) is logged, not followed. A failure is logged once while it lasts. Windows has none of this: there herdr is a beta on named pipes.
|
||||
- 2026-10-16: herdr 0.9 keeps its panes' OSC 7 and writes only a title, notifications, OSC 52 and bells to the terminal it runs in, so no terminal hears a folder from inside it; herdr's server knows each pane's `cwd`, the folder its shell reported, else the shell's own. On macOS and Linux each session asks twice a second which process group leads its pty's foreground (`tcgetpgrp` on a copy of the master) and what executable that group's leader runs: an exec keeps the group and swaps the executable, so `exec herdr` from a shell is a new program, and one that did not read is tried again; on Linux the ` (deleted)` mark an update leaves on a running executable is set aside. When that executable is `herdr` and its arguments start herdr's terminal client (no command, `session attach NAME` or `client`), ELYTerminal reads the client's arguments and variables (`/proc` on Linux; `KERN_PROCARGS2` on macOS, which hides only the variables of Apple's own programs) and finds its socket as herdr 0.9 does: `--session NAME`, then `HERDR_SOCKET_PATH`, then `HERDR_SESSION`, under `$XDG_CONFIG_HOME/herdr`, else `~/.config/herdr`; a relative socket lies in the client's folder, as herdr reads it (`/proc/PID/cwd`; `proc_pidinfo` on macOS). It asks that socket for `session.snapshot` twice a second, a connection each, since herdr closes one after its reply, and reports the focused pane's `cwd` as the session's folder when it changes and again each time herdr takes the foreground. Focus belongs to herdr's server, so clients of one server follow one focus; a client of another machine's server (`--remote`, `--machine`) is logged, not followed. A failure is logged once while it lasts. Windows has none of this: there herdr is a beta on named pipes.
|
||||
+95
-25
@@ -28,37 +28,60 @@ type Invocation = (Vec<String>, HashMap<String, OsString>);
|
||||
/// Follows the pane herdr's server has focused while herdr's client runs in the foreground.
|
||||
pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||
cx.spawn(async move |view, cx| {
|
||||
let (mut group, mut socket, mut shown, mut failed) = (None, None, None, String::new());
|
||||
let mut seen: Option<(i32, Option<PathBuf>)> = None;
|
||||
let (mut socket, mut shown, mut retry, mut failed) = (None, None, false, String::new());
|
||||
loop {
|
||||
cx.background_executor().timer(CHECK).await;
|
||||
let Ok((pid, now)) = view.update(cx, |view, _| (view.pty.pid, view.pty.foreground()))
|
||||
let Ok((pid, group)) = view.update(cx, |view, _| (view.pty.pid, view.pty.foreground()))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let now = match now {
|
||||
Ok(now) => Some(now),
|
||||
let group = match group {
|
||||
Ok(group) => Some(group),
|
||||
Err(error) => {
|
||||
if group.is_some() {
|
||||
if seen.is_some() {
|
||||
log::debug!("terminal {pid}: its foreground does not read: {error}");
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
if now != group {
|
||||
group = now;
|
||||
// An exec keeps the group and swaps the program, so the two name the foreground.
|
||||
let program = match group {
|
||||
Some(group) => {
|
||||
let exe = cx
|
||||
.background_executor()
|
||||
.spawn(async move { path(group).ok() })
|
||||
.await;
|
||||
Some((group, exe))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if program != seen {
|
||||
if let Some((group, None)) = &program {
|
||||
log::debug!(
|
||||
"terminal {pid}: process {group}, another user's or gone, does not read"
|
||||
);
|
||||
}
|
||||
seen = program.clone();
|
||||
shown = None;
|
||||
retry = true;
|
||||
failed.clear();
|
||||
if socket.take().is_some() {
|
||||
log::info!("terminal {pid}: herdr left the foreground");
|
||||
}
|
||||
let found = match now {
|
||||
Some(now) => {
|
||||
}
|
||||
if retry {
|
||||
let found = match program {
|
||||
Some((group, Some(exe)))
|
||||
if exe.file_name().is_some_and(|name| name == "herdr") =>
|
||||
{
|
||||
cx.background_executor()
|
||||
.spawn(async move { client(now) })
|
||||
.spawn(async move { client(group) })
|
||||
.await
|
||||
}
|
||||
None => Ok(None),
|
||||
_ => Ok(None),
|
||||
};
|
||||
retry = found.is_err();
|
||||
socket = found.unwrap_or_else(|error| {
|
||||
failure(&mut failed, pid, &error);
|
||||
None
|
||||
@@ -130,22 +153,20 @@ fn starting(error: &anyhow::Error) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The socket of the herdr server whose terminal client leads process group `group`, if one does.
|
||||
/// The socket of the herdr server whose terminal client leads process group `group`, if it is one.
|
||||
fn client(group: i32) -> anyhow::Result<Option<PathBuf>> {
|
||||
let path = match path(group) {
|
||||
Ok(path) => path,
|
||||
// Another user's program, such as sudo, or one already gone.
|
||||
Err(error) => {
|
||||
log::debug!("terminal: process {group} does not read: {error}");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if path.file_name().is_none_or(|name| name != "herdr") {
|
||||
return Ok(None);
|
||||
}
|
||||
let (args, env) =
|
||||
launch(group).with_context(|| format!("herdr: process {group} does not read"))?;
|
||||
follows(&args, &env)
|
||||
let Some(socket) = follows(&args, &env)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if socket.is_absolute() {
|
||||
return Ok(Some(socket));
|
||||
}
|
||||
// herdr reads a relative socket from its client's folder.
|
||||
let folder =
|
||||
cwd(group).with_context(|| format!("herdr: process {group}'s folder does not read"))?;
|
||||
Ok(Some(folder.join(socket)))
|
||||
}
|
||||
|
||||
/// The socket a herdr process with `args` and `env` shows, if it is herdr's terminal client.
|
||||
@@ -305,7 +326,17 @@ pub(super) fn focused(reply: &str) -> anyhow::Result<Option<PathBuf>> {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn path(pid: i32) -> io::Result<PathBuf> {
|
||||
std::fs::read_link(format!("/proc/{pid}/exe"))
|
||||
std::fs::read_link(format!("/proc/{pid}/exe")).map(installed)
|
||||
}
|
||||
|
||||
/// The path of an executable the kernel marks ` (deleted)`, as an update that replaced it leaves it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn installed(path: PathBuf) -> PathBuf {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
match path.as_os_str().as_bytes().strip_suffix(b" (deleted)") {
|
||||
Some(kept) => PathBuf::from(OsStr::from_bytes(kept)),
|
||||
None => path,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -317,6 +348,11 @@ fn launch(pid: i32) -> io::Result<Invocation> {
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cwd(pid: i32) -> io::Result<PathBuf> {
|
||||
std::fs::read_link(format!("/proc/{pid}/cwd"))
|
||||
}
|
||||
|
||||
/// Each NUL-ended string in `bytes`.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn strings(bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
@@ -373,6 +409,40 @@ fn launch(pid: i32) -> io::Result<Invocation> {
|
||||
procargs(&buffer)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cwd(pid: i32) -> io::Result<PathBuf> {
|
||||
// SAFETY: the struct is plain C data, valid zeroed.
|
||||
let mut info: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() };
|
||||
let size = std::mem::size_of::<libc::proc_vnodepathinfo>() as libc::c_int;
|
||||
// SAFETY: `info` holds the size given.
|
||||
let read = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid,
|
||||
libc::PROC_PIDVNODEPATHINFO,
|
||||
0,
|
||||
(&raw mut info).cast(),
|
||||
size,
|
||||
)
|
||||
};
|
||||
if read <= 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if read != size {
|
||||
return Err(io::Error::other(format!(
|
||||
"proc_pidinfo read {read} of {size} bytes"
|
||||
)));
|
||||
}
|
||||
let path = info
|
||||
.pvi_cdir
|
||||
.vip_path
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|byte| *byte as u8);
|
||||
Ok(PathBuf::from(OsString::from_vec(
|
||||
path.take_while(|byte| *byte != 0).collect(),
|
||||
)))
|
||||
}
|
||||
|
||||
/// `KERN_PROCARGS2`'s layout: argc, the program's path, NUL padding, the arguments, the variables.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn procargs(buffer: &[u8]) -> io::Result<Invocation> {
|
||||
|
||||
+22
-1
@@ -38,6 +38,8 @@ fn a_client_finds_its_server_as_herdr_does() {
|
||||
let socket = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "/s.sock")]);
|
||||
assert_eq!(found("herdr", &socket), PathBuf::from("/s.sock"));
|
||||
assert_eq!(found("herdr --session work", &socket), work);
|
||||
let relative = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "herdr.sock")]);
|
||||
assert_eq!(found("herdr", &relative), PathBuf::from("herdr.sock"));
|
||||
let named = env(&[("HOME", "/h"), ("HERDR_SESSION", "work")]);
|
||||
assert_eq!(found("herdr", &named), work);
|
||||
assert!(follows(&args("herdr"), &env(&[])).is_err());
|
||||
@@ -120,7 +122,7 @@ fn herdr_answers_over_its_socket() {
|
||||
|
||||
/// macOS hides the variables of its own programs, such as `/bin/sleep`, so the test reads itself.
|
||||
#[test]
|
||||
fn a_process_reads_with_its_path_arguments_and_variables() {
|
||||
fn a_process_reads_with_its_path_arguments_variables_and_folder() {
|
||||
let pid = std::process::id() as i32;
|
||||
let read = path(pid).expect("its path");
|
||||
let (args, env) = launch(pid).expect("its arguments");
|
||||
@@ -128,6 +130,25 @@ fn a_process_reads_with_its_path_arguments_and_variables() {
|
||||
assert_eq!(fs::canonicalize(read).ok(), fs::canonicalize(exe).ok());
|
||||
assert_eq!(args, std::env::args().collect::<Vec<_>>());
|
||||
assert_eq!(env.get("HOME"), std::env::var_os("HOME").as_ref());
|
||||
let folder = std::env::current_dir().expect("this test's folder");
|
||||
assert_eq!(
|
||||
fs::canonicalize(cwd(pid).expect("its folder")).ok(),
|
||||
fs::canonicalize(folder).ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_updated_herdr_still_names_itself() {
|
||||
let deleted = PathBuf::from("/home/u/.local/bin/herdr (deleted)");
|
||||
assert_eq!(
|
||||
installed(deleted),
|
||||
PathBuf::from("/home/u/.local/bin/herdr")
|
||||
);
|
||||
assert_eq!(
|
||||
installed("/usr/bin/herdr".into()),
|
||||
PathBuf::from("/usr/bin/herdr")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|---|
|
||||
| PTY through alacritty's event loop; OSC 7 scanned on the reader | T04 | `term::pty`, `term::tap` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass |
|
||||
| Shell integration for zsh, bash, fish, PowerShell; a shell without it reports nothing and the panels say so | T05 | `term::shell` tests on macOS and Linux | Pass |
|
||||
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS and Linux | T29 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report; on macOS under `shot`, herdr 0.9.5 as the program, its pane's cd moved the panel | Pass |
|
||||
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS and Linux | T29 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report, then `exec herdr` from that bash, with `HERDR_SOCKET_PATH` relative to its folder, was followed through the socket there; on macOS under `shot`, herdr 0.9.5 as the program and again by `exec` from `sh` with a relative socket, its pane's cd moved the panel | Pass |
|
||||
| Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass |
|
||||
| Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass |
|
||||
| JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |
|
||||
|
||||
Reference in new issue
Block a user