Compare commits
6
Commits
v0.1.2
...
f85e4c1169
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85e4c1169
|
||
|
|
3b5bf46093
|
||
|
|
d7dc1af6b9
|
||
|
|
7cb2dffc45
|
||
|
|
9a0c0d9dbb
|
||
|
|
2a1060ce48
|
No files matched your search
@@ -9,6 +9,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
|
||||
- `serde`, `serde_json`: settings. `dirs`: the config folder. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
|
||||
- On Linux, `wayland-client`, `wayland-backend` and `wayland-protocols` (staging) ask the compositor for `ext_background_effect_v1` on gpui's own connection; `wayland-server` hosts a compositor in the tests. `x11rb` sets `_KDE_NET_WM_BLUR_BEHIND_REGION`; its test runs Xvfb.
|
||||
- On Windows, `sysinfo` reads herdr's client's arguments and variables; `windows-sys`, the version the Windows build already holds, lists processes and waits on herdr's pipe.
|
||||
- `resvg`, a dev dependency at the version gpui uses, renders the icon's SVG for the macOS app and the Windows `.ico`. `embed-resource`, a build dependency at the version gpui uses, puts the icon into the Windows executable.
|
||||
|
||||
## Commands
|
||||
@@ -33,7 +34,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window, at the size the settings keep, which it keeps as it settles. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/macos` gives gpui's NSVisualEffectView a material that blurs; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page, `window/terminal` its Terminal page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser; `herdr` follows herdr's focused pane through herdr's socket while herdr's client leads the pty's foreground. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser; `herdr` follows herdr's focused pane through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
|
||||
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
@@ -92,5 +93,5 @@ 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) 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.
|
||||
- 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; an answer that lands after the shell reported a folder is dropped, since the shell reports only once herdr has left. 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. On Windows, which has no foreground group, a Toolhelp32 snapshot lists the `herdr.exe` processes at or under the session's shell, none under another herdr, since what a herdr starts, its server too, is its own; when that list changes, `sysinfo` reads their arguments and variables, nearest first, names in upper case, as Windows matches them, and the first terminal client is followed, a server or a command beside it passed by; its folder is found as herdr 0.9 finds it there, `XDG_CONFIG_HOME`, then `APPDATA`, then `USERPROFILE\AppData\Roaming`, then `HOME\.config`; and its server answers on the pipe herdr names `\\.\pipe\` and the socket's path as written, relative or not. A busy pipe is waited for, as herdr's client waits, and the pipe's server may identify ELYTerminal but never act as it. The pipe has no timeout: a herdr that never answers holds that session's follow until it answers or exits.
|
||||
- 2026-10-16: the main window opens at the size it was left at, which the owner asked for on macOS, Windows and Linux: `window_width` and `window_height` in the settings hold the size gpui's `window_bounds` gives 0.5 s after the bounds settle, saved through `SettingsStore::update`, which writes only a change, so a drag saves once and a move saves nothing. A maximized, full-screen or hidden window keeps the size from before; its state and size are read once they settle, since X11 marks a window maximized after its new size, gpui's X11 window then gives a maximized window's own size, and it calls a maximized window unmaximized while minimized. A window that shows again, uncovered, restored or back on its screen, is read again, so a size it took just before it hid is kept; a size it takes in the half second before it closes, or before it hides until the app quits, is lost. The window is no smaller than 100 by 100, the least the settings keep, or a smaller size the platform allows could not be kept. The window opens centered at whole points, since macOS grows a window by a point on a side whose edge falls on a half point; its place is not kept, and a kept side past the primary display's visible area shrinks to it, logged. A size outside 100 to 16384 stops the start, as any value out of range does.
|
||||
Generated
+27
-1
@@ -1774,11 +1774,13 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"signal-hook",
|
||||
"sysinfo 0.39.6",
|
||||
"unicode-normalization",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-server",
|
||||
"windows-sys 0.61.2",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -4463,6 +4465,16 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -6509,6 +6521,20 @@ dependencies = [
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.39.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-fonts"
|
||||
version = "0.1.2"
|
||||
@@ -8555,7 +8581,7 @@ dependencies = [
|
||||
"rand 0.8.8",
|
||||
"screencapturekit",
|
||||
"screencapturekit-sys",
|
||||
"sysinfo",
|
||||
"sysinfo 0.31.4",
|
||||
"tao-core-video-sys",
|
||||
"windows 0.61.3",
|
||||
"windows-capture",
|
||||
|
||||
@@ -57,6 +57,13 @@ core-graphics = { version = "0.24", features = ["highsierra"] }
|
||||
foreign-types = "0.5"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
sysinfo = { version = "0.39", default-features = false, features = ["system"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Pipes"] }
|
||||
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_System_IO"] }
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
|
||||
- PTY: alacritty's `tty` and event loop. The pty is wrapped so its reader passes bytes through an OSC 7 scanner. The scanner reports the working directory.
|
||||
- Shell integration for zsh, bash, fish and PowerShell, injected at launch as Ghostty does: each prompt sends OSC 7 and a title. A shell without integration reports nothing, and the panels say so.
|
||||
- Inside herdr, which keeps its panes' OSC 7, the session's folder is that of herdr's focused pane, read from herdr's socket while herdr's client runs in the foreground, on macOS and Linux.
|
||||
- Inside herdr, which keeps its panes' OSC 7, the session's folder is that of herdr's focused pane, read from herdr's socket while herdr's client runs in the session, on macOS, Linux and Windows.
|
||||
- Grid: colors (named, 256, truecolor, OSC 4/10/11/12), bold, dim, italic, underline (single, double, curly, dotted, dashed), strikethrough, inverse, hidden, wide characters, combining marks.
|
||||
- Box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) painted as geometry, so lines and segments join across cells.
|
||||
- Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font.
|
||||
|
||||
@@ -45,7 +45,7 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
||||
- Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title.
|
||||
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound.
|
||||
- A button at each end of the title bar hides or shows its side column; the window opens as it was left, at the size it was left at. On Windows and Linux a gear beside the right one opens Settings.
|
||||
- Files shows the session's folder as a tree with Git's marks. Inside herdr, on macOS and Linux, the folder is that of herdr's focused pane.
|
||||
- Files shows the session's folder as a tree with Git's marks. Inside herdr the folder is that of herdr's focused pane.
|
||||
- Git shows the branch, stages, unstages, discards, commits, lists recent commits and opens diffs, all through the `git` command line.
|
||||
- Themes: Ely Dark, Ely Light, Catppuccin Mocha, Macchiato, Frappé and Latte, Tokyo Night, Dracula, Nord, Gruvbox Dark, Rosé Pine, Solarized Light, One Light and Kanagawa Wave. A second theme can take over while the system is dark. On Omarchy 4, an Omarchy theme follows the system's theme as it switches, and is the default there.
|
||||
- Background opacity and blur: NSVisualEffectView on macOS, acrylic on Windows, `ext_background_effect_v1` or `org_kde_kwin_blur` on Wayland, `_KDE_NET_WM_BLUR_BEHIND_REGION` on X11, which KWin reads. Blur shows only while the opacity is under 100%. Hyprland blurs only while its own `decoration:blur:enabled` is on, and Omarchy 4.0.4 turns it off; to turn it on there, add `hl.config({ decoration = { blur = { enabled = true } } })` to `~/.config/hypr/looknfeel.lua`.
|
||||
|
||||
@@ -82,4 +82,5 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
|
||||
|
||||
- [x] T29 Inside herdr the right column follows herdr's focused pane, which the owner found frozen: herdr keeps its panes' OSC 7, so on macOS and Linux ELYTerminal reads the folder from herdr's socket while herdr's client runs in the session's foreground.
|
||||
- [x] T30 The main window opens at the size it was left at, on macOS, Windows and Linux, which the owner asked for: the size is kept in the settings.
|
||||
- [ ] T31 Release v0.1.2 with T29 and T30: version 0.1.2; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes say it fixes herdr compatibility and keeps the window's size.
|
||||
- [x] T31 Release v0.1.2 with T29 and T30: version 0.1.2; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes say it fixes herdr compatibility and keeps the window's size.
|
||||
- [x] T32 On Windows the right column follows herdr's focused pane too, which the owner found frozen there: ELYTerminal finds herdr's client under the session's shell, reads its arguments and variables, and asks herdr's named pipe.
|
||||
+34
-208
@@ -2,7 +2,6 @@ use std::{
|
||||
collections::HashMap,
|
||||
ffi::{OsStr, OsString},
|
||||
io::{self, BufRead, BufReader, Write},
|
||||
os::unix::{ffi::OsStringExt, net::UnixStream},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -13,6 +12,15 @@ use serde::Deserialize;
|
||||
|
||||
use super::view::{TerminalEvent, TerminalView};
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
#[cfg(unix)]
|
||||
use unix::{Program, client, connect, home, leader, running};
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
#[cfg(windows)]
|
||||
use windows::{Program, client, connect, home, leader, running};
|
||||
|
||||
/// How often the session's foreground program is checked.
|
||||
const CHECK: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -22,22 +30,31 @@ const WAIT: Duration = Duration::from_secs(1);
|
||||
/// The socket request for herdr's session: its focus and its panes' folders.
|
||||
const SNAPSHOT: &[u8] = b"{\"id\":\"elyterminal\",\"method\":\"session.snapshot\",\"params\":{}}\n";
|
||||
|
||||
/// A program's arguments, and its variables as the kernel holds them.
|
||||
/// A program's arguments, and its variables as the system holds them.
|
||||
type Invocation = (Vec<String>, HashMap<String, OsString>);
|
||||
|
||||
/// Follows the pane herdr's server has focused while herdr's client runs in the foreground.
|
||||
/// Follows the pane herdr's server has focused while herdr's client runs in the session.
|
||||
pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||
cx.spawn(async move |view, cx| {
|
||||
let mut seen: Option<(i32, Option<PathBuf>)> = None;
|
||||
let mut seen: Option<Program> = 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, group)) = view.update(cx, |view, _| (view.pty.pid, view.pty.foreground()))
|
||||
else {
|
||||
let Ok((pid, lead, reports)) = view.update(cx, |view, _| {
|
||||
(view.pty.pid, leader(&view.pty), view.reports)
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
let group = match group {
|
||||
Ok(group) => Some(group),
|
||||
let program = match lead {
|
||||
Ok(lead) => {
|
||||
cx.background_executor()
|
||||
.spawn(async move { running(lead) })
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let program = match program {
|
||||
Ok(program) => Some(program),
|
||||
Err(error) => {
|
||||
if seen.is_some() {
|
||||
log::debug!("terminal {pid}: its foreground does not read: {error}");
|
||||
@@ -45,23 +62,7 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||
None
|
||||
}
|
||||
};
|
||||
// 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;
|
||||
@@ -72,14 +73,12 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||
}
|
||||
if retry {
|
||||
let found = match program {
|
||||
Some((group, Some(exe)))
|
||||
if exe.file_name().is_some_and(|name| name == "herdr") =>
|
||||
{
|
||||
Some(program) => {
|
||||
cx.background_executor()
|
||||
.spawn(async move { client(group) })
|
||||
.spawn(async move { client(program) })
|
||||
.await
|
||||
}
|
||||
_ => Ok(None),
|
||||
None => Ok(None),
|
||||
};
|
||||
retry = found.is_err();
|
||||
socket = found.unwrap_or_else(|error| {
|
||||
@@ -104,9 +103,9 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||
Ok(Some(folder)) if shown.as_ref() != Some(&folder) => {
|
||||
failed.clear();
|
||||
let report = folder.clone();
|
||||
// herdr may have left while it answered; the shell's own report then stands.
|
||||
// The shell reports only once herdr has left; its folder then stands.
|
||||
let Ok(still) = view.update(cx, |view, cx| {
|
||||
let still = view.pty.foreground().ok() == group;
|
||||
let still = view.reports == reports;
|
||||
if still {
|
||||
cx.emit(TerminalEvent::Folder(report));
|
||||
}
|
||||
@@ -153,22 +152,6 @@ fn starting(error: &anyhow::Error) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 (args, env) =
|
||||
launch(group).with_context(|| format!("herdr: process {group} does not read"))?;
|
||||
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.
|
||||
pub(super) fn follows(
|
||||
args: &[String],
|
||||
@@ -209,10 +192,9 @@ fn attaches(args: &[String]) -> bool {
|
||||
|
||||
/// The socket herdr 0.9's client finds: a named session, `HERDR_SOCKET_PATH`, `HERDR_SESSION`, the default.
|
||||
fn socket(args: &[String], env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||
let dir = match (env.get("XDG_CONFIG_HOME"), env.get("HOME")) {
|
||||
(Some(config), _) => PathBuf::from(config).join("herdr"),
|
||||
(None, Some(home)) => PathBuf::from(home).join(".config/herdr"),
|
||||
(None, None) => anyhow::bail!("herdr: its client has no HOME"),
|
||||
let dir = match env.get("XDG_CONFIG_HOME") {
|
||||
Some(config) => PathBuf::from(config).join("herdr"),
|
||||
None => home(env)?,
|
||||
};
|
||||
let named = |name: &OsStr| {
|
||||
if name == "default" {
|
||||
@@ -258,9 +240,7 @@ fn session(args: &[String]) -> Option<String> {
|
||||
/// The folder of the pane herdr's server has focused; none while it has none or knows none.
|
||||
fn folder(socket: &Path) -> anyhow::Result<Option<PathBuf>> {
|
||||
let ask = || -> io::Result<String> {
|
||||
let mut stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(WAIT))?;
|
||||
stream.set_write_timeout(Some(WAIT))?;
|
||||
let mut stream = connect(socket)?;
|
||||
stream.write_all(SNAPSHOT)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(stream).read_line(&mut reply)?;
|
||||
@@ -324,159 +304,5 @@ pub(super) fn focused(reply: &str) -> anyhow::Result<Option<PathBuf>> {
|
||||
Ok(pane.cwd)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn path(pid: i32) -> io::Result<PathBuf> {
|
||||
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")]
|
||||
fn launch(pid: i32) -> io::Result<Invocation> {
|
||||
let read = |name: &str| std::fs::read(format!("/proc/{pid}/{name}"));
|
||||
let (args, env) = (read("cmdline")?, read("environ")?);
|
||||
let args = strings(&args).map(lossy).collect();
|
||||
let env = strings(&env).filter_map(variable).collect();
|
||||
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]> {
|
||||
bytes
|
||||
.strip_suffix(b"\0")
|
||||
.unwrap_or(bytes)
|
||||
.split(|byte| *byte == 0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn path(pid: i32) -> io::Result<PathBuf> {
|
||||
let mut buffer = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: the buffer holds the size given.
|
||||
let length =
|
||||
unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
|
||||
if length <= 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
buffer.truncate(length as usize);
|
||||
Ok(PathBuf::from(OsString::from_vec(buffer)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch(pid: i32) -> io::Result<Invocation> {
|
||||
let mut name = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
|
||||
let mut ask = |buffer: &mut Vec<u8>| {
|
||||
let mut size = buffer.len();
|
||||
let into = if buffer.is_empty() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
buffer.as_mut_ptr().cast()
|
||||
};
|
||||
// SAFETY: `into` holds `size` bytes, or is null to ask for the size.
|
||||
let done = unsafe {
|
||||
libc::sysctl(
|
||||
name.as_mut_ptr(),
|
||||
3,
|
||||
into,
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if done != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(size)
|
||||
};
|
||||
let mut buffer = Vec::new();
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.resize(size, 0);
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.truncate(size);
|
||||
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> {
|
||||
let invalid =
|
||||
|what: &str| io::Error::new(io::ErrorKind::InvalidData, format!("KERN_PROCARGS2 {what}"));
|
||||
let (count, rest) = buffer
|
||||
.split_first_chunk::<4>()
|
||||
.ok_or_else(|| invalid("holds no argument count"))?;
|
||||
let count = usize::try_from(i32::from_ne_bytes(*count))
|
||||
.map_err(|_| invalid("holds a negative argument count"))?;
|
||||
let mut parts = rest
|
||||
.split(|byte| *byte == 0)
|
||||
.skip(1)
|
||||
.skip_while(|part| part.is_empty());
|
||||
let args = parts.by_ref().take(count).map(lossy).collect();
|
||||
let env = parts
|
||||
.take_while(|part| !part.is_empty())
|
||||
.filter_map(variable)
|
||||
.collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
fn lossy(bytes: &[u8]) -> String {
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
|
||||
/// A `NAME=value` variable; its value keeps its bytes.
|
||||
fn variable(pair: &[u8]) -> Option<(String, OsString)> {
|
||||
let at = pair.iter().position(|byte| *byte == b'=')?;
|
||||
Some((
|
||||
lossy(&pair[..at]),
|
||||
OsString::from_vec(pair[at + 1..].to_vec()),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+174
-5
@@ -1,7 +1,8 @@
|
||||
use std::{fs, os::unix::net::UnixListener};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A root that makes a path absolute here.
|
||||
const ROOT: &str = if cfg!(windows) { "C:/" } else { "/" };
|
||||
|
||||
fn args(words: &str) -> Vec<String> {
|
||||
words.split(' ').map(str::to_owned).collect()
|
||||
}
|
||||
@@ -73,11 +74,15 @@ fn reply(focused_pane: &str, cwd: &str) -> String {
|
||||
#[test]
|
||||
fn the_focused_pane_s_folder_comes_from_herdr_s_snapshot() {
|
||||
let read = |focused_pane: &str, cwd: &str| focused(&reply(focused_pane, cwd));
|
||||
let folder = format!("{ROOT}srv/b");
|
||||
assert_eq!(
|
||||
read(r#""w1:p2""#, r#""/srv/b""#).expect("a folder"),
|
||||
Some("/srv/b".into())
|
||||
read(r#""w1:p2""#, &format!("\"{folder}\"")).expect("a folder"),
|
||||
Some(folder.clone().into())
|
||||
);
|
||||
assert_eq!(
|
||||
read("null", &format!("\"{folder}\"")).expect("no focus"),
|
||||
None
|
||||
);
|
||||
assert_eq!(read("null", r#""/srv/b""#).expect("no focus"), None);
|
||||
assert_eq!(read(r#""w1:p2""#, "null").expect("no folder"), None);
|
||||
let error = read(r#""w1:p9""#, r#""/b""#).expect_err("a lost pane");
|
||||
assert!(error.to_string().contains("w1:p9"), "{error}");
|
||||
@@ -90,8 +95,10 @@ fn the_focused_pane_s_folder_comes_from_herdr_s_snapshot() {
|
||||
assert!(focused("").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn herdr_answers_over_its_socket() {
|
||||
use std::{fs, os::unix::net::UnixListener};
|
||||
let dir = std::env::temp_dir().join(format!("elyterminal-herdr-{}", std::process::id()));
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir).expect("a stale test folder goes");
|
||||
@@ -121,8 +128,11 @@ fn herdr_answers_over_its_socket() {
|
||||
}
|
||||
|
||||
/// macOS hides the variables of its own programs, such as `/bin/sleep`, so the test reads itself.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_process_reads_with_its_path_arguments_variables_and_folder() {
|
||||
use super::unix::{cwd, launch, path};
|
||||
use std::fs;
|
||||
let pid = std::process::id() as i32;
|
||||
let read = path(pid).expect("its path");
|
||||
let (args, env) = launch(pid).expect("its arguments");
|
||||
@@ -137,9 +147,22 @@ fn a_process_reads_with_its_path_arguments_variables_and_folder() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_herdr_s_program_is_read() {
|
||||
let pid = std::process::id() as i32;
|
||||
assert_eq!(
|
||||
client((pid, None)).expect("a program that does not read"),
|
||||
None
|
||||
);
|
||||
let shell = client((pid, Some("/bin/sh".into())));
|
||||
assert_eq!(shell.expect("another program"), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_updated_herdr_still_names_itself() {
|
||||
use super::unix::installed;
|
||||
let deleted = PathBuf::from("/home/u/.local/bin/herdr (deleted)");
|
||||
assert_eq!(
|
||||
installed(deleted),
|
||||
@@ -154,6 +177,7 @@ fn an_updated_herdr_still_names_itself() {
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_lays_out_the_arguments_after_the_program_s_path() {
|
||||
use super::unix::procargs;
|
||||
let mut buffer = 2i32.to_ne_bytes().to_vec();
|
||||
buffer.extend_from_slice(b"/usr/local/bin/herdr\0\0\0\0herdr\0\0HOME=/h\0X=a=b\0\0\0");
|
||||
let (args, env) = procargs(&buffer).expect("it parses");
|
||||
@@ -162,3 +186,148 @@ fn macos_lays_out_the_arguments_after_the_program_s_path() {
|
||||
assert_eq!(env.get("X"), Some(&OsString::from("a=b")));
|
||||
assert!(procargs(&[1, 0]).is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_windows_client_finds_its_server_in_app_data() {
|
||||
let roaming = PathBuf::from(r"C:\Users\u\AppData\Roaming\herdr");
|
||||
let data = env(&[
|
||||
("APPDATA", r"C:\Users\u\AppData\Roaming"),
|
||||
("USERPROFILE", r"C:\D"),
|
||||
]);
|
||||
assert_eq!(found("herdr", &data), roaming.join("herdr.sock"));
|
||||
let profile = env(&[("USERPROFILE", r"C:\Users\u")]);
|
||||
assert_eq!(
|
||||
found("herdr --session work", &profile),
|
||||
roaming.join(r"sessions\work\herdr.sock")
|
||||
);
|
||||
let config = env(&[("XDG_CONFIG_HOME", r"C:\x"), ("APPDATA", r"C:\D")]);
|
||||
assert_eq!(
|
||||
found("herdr", &config),
|
||||
PathBuf::from(r"C:\x\herdr\herdr.sock")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn herdr_is_sought_at_and_under_the_shell_not_under_herdr() {
|
||||
use super::windows::herdrs;
|
||||
let processes = [
|
||||
(100, 50, "pwsh.exe"),
|
||||
(104, 100, "cmd.exe"),
|
||||
(108, 104, "HERDR.EXE"),
|
||||
(112, 100, "herdr.exe"),
|
||||
(116, 112, "herdr.exe"),
|
||||
(120, 100, "herdr.exe"),
|
||||
(200, 300, "a.exe"),
|
||||
(300, 200, "b.exe"),
|
||||
]
|
||||
.map(|(pid, parent, name)| (pid, parent, name.to_owned()));
|
||||
// 116 is the server 112 started; 120 one started beside it.
|
||||
assert_eq!(herdrs(&processes, 100), [112, 120, 108]);
|
||||
assert_eq!(herdrs(&processes, 112), [112]);
|
||||
assert!(herdrs(&processes, 200).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_server_or_a_command_beside_herdr_s_client_is_passed_by() {
|
||||
use super::windows::pick;
|
||||
let data = env(&[("APPDATA", r"C:\D")]);
|
||||
let read = |words: &str| -> io::Result<Invocation> { Ok((args(words), data.clone())) };
|
||||
let gone = || -> io::Result<Invocation> { Err(io::ErrorKind::NotFound.into()) };
|
||||
let socket = Some(PathBuf::from(r"C:\D\herdr\herdr.sock"));
|
||||
let server = "herdr.exe server";
|
||||
let client = [(200, read(server)), (100, read("herdr.exe"))];
|
||||
assert_eq!(pick(client).expect("a client"), socket);
|
||||
let command = [(200, read(server)), (300, read("herdr.exe api snapshot"))];
|
||||
assert_eq!(pick(command).expect("no client"), None);
|
||||
let unread = [(300, gone()), (100, read("herdr.exe"))];
|
||||
assert_eq!(pick(unread).expect("a client"), socket);
|
||||
let error = pick([(300, gone()), (200, read(server))]).expect_err("an unread process");
|
||||
assert!(format!("{error:#}").contains("process 300"), "{error:#}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_windows_process_reads_with_its_name_arguments_and_variables() {
|
||||
use super::windows::{launch, processes};
|
||||
let pid = std::process::id();
|
||||
let exe = std::env::current_exe().expect("this test's path");
|
||||
let name = exe.file_name().expect("this test's name");
|
||||
let listed = processes().expect("the process list");
|
||||
assert!(
|
||||
listed
|
||||
.iter()
|
||||
.any(|(id, _, read)| *id == pid && name.eq_ignore_ascii_case(read)),
|
||||
"process {pid} is not listed under its name"
|
||||
);
|
||||
assert!(running(pid).expect("the process list").is_empty());
|
||||
let (args, env) = launch(pid).expect("its arguments");
|
||||
assert_eq!(args, std::env::args().collect::<Vec<_>>());
|
||||
// Windows names it Path; the reader folds names to upper case.
|
||||
let path = std::env::var_os("PATH");
|
||||
assert!(env.get("PATH") == path.as_ref(), "PATH reads otherwise");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn herdr_answers_over_its_pipe() {
|
||||
use std::os::windows::{
|
||||
ffi::OsStrExt,
|
||||
io::{AsRawHandle, FromRawHandle, OwnedHandle},
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{ERROR_PIPE_CONNECTED, INVALID_HANDLE_VALUE},
|
||||
Storage::FileSystem::PIPE_ACCESS_DUPLEX,
|
||||
System::Pipes::{ConnectNamedPipe, CreateNamedPipeW, PIPE_TYPE_BYTE},
|
||||
};
|
||||
let socket = PathBuf::from(format!(
|
||||
r"C:\elyterminal-herdr-{}\herdr.sock",
|
||||
std::process::id()
|
||||
));
|
||||
let name = format!(r"\\.\pipe\{}", socket.display());
|
||||
let name: Vec<u16> = OsStr::new(&name).encode_wide().chain([0]).collect();
|
||||
// SAFETY: `name` ends with a NUL; the handle is owned below.
|
||||
let pipe = unsafe {
|
||||
CreateNamedPipeW(
|
||||
name.as_ptr(),
|
||||
PIPE_ACCESS_DUPLEX,
|
||||
PIPE_TYPE_BYTE,
|
||||
1,
|
||||
4096,
|
||||
4096,
|
||||
0,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
assert_ne!(pipe, INVALID_HANDLE_VALUE, "{}", io::Error::last_os_error());
|
||||
// SAFETY: the handle is new and ours.
|
||||
let pipe = unsafe { OwnedHandle::from_raw_handle(pipe) };
|
||||
let answer = reply(r#""w1:p2""#, r#""C:/srv""#);
|
||||
let server = std::thread::spawn(move || {
|
||||
// SAFETY: the pipe is open.
|
||||
let joined = unsafe { ConnectNamedPipe(pipe.as_raw_handle(), std::ptr::null_mut()) };
|
||||
let error = io::Error::last_os_error();
|
||||
assert!(
|
||||
joined != 0 || error.raw_os_error() == Some(ERROR_PIPE_CONNECTED as i32),
|
||||
"{error}"
|
||||
);
|
||||
let stream = std::fs::File::from(pipe);
|
||||
let mut request = String::new();
|
||||
BufReader::new(&stream)
|
||||
.read_line(&mut request)
|
||||
.expect("a request");
|
||||
(&stream)
|
||||
.write_all(format!("{answer}\n").as_bytes())
|
||||
.expect("a reply");
|
||||
// The pipe goes only once the client has read the reply.
|
||||
stream.sync_all().expect("the client reads the reply");
|
||||
request
|
||||
});
|
||||
assert_eq!(folder(&socket).expect("an answer"), Some("C:/srv".into()));
|
||||
assert_eq!(server.join().expect("the server").as_bytes(), SNAPSHOT);
|
||||
let error = folder(&socket).expect_err("no server");
|
||||
assert!(format!("{error:#}").contains("asking"), "{error:#}");
|
||||
assert!(starting(&error), "{error:#}");
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsString,
|
||||
io,
|
||||
os::unix::{ffi::OsStringExt, net::UnixStream},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
|
||||
use super::{Invocation, WAIT, follows};
|
||||
use crate::term::Pty;
|
||||
|
||||
pub(super) type Pid = i32;
|
||||
|
||||
/// The foreground's group and its leader's executable: an exec keeps the group and swaps the program.
|
||||
pub(super) type Program = (Pid, Option<PathBuf>);
|
||||
|
||||
/// herdr's executable.
|
||||
const HERDR: &str = "herdr";
|
||||
|
||||
/// The process group leading the session's foreground.
|
||||
pub(super) fn leader(pty: &Pty) -> io::Result<Pid> {
|
||||
pty.foreground()
|
||||
}
|
||||
|
||||
pub(super) fn running(group: Pid) -> io::Result<Program> {
|
||||
Ok((group, path(group).ok()))
|
||||
}
|
||||
|
||||
/// The socket of the herdr server whose terminal client leads process group `group`, if it is one.
|
||||
pub(super) fn client((group, exe): Program) -> anyhow::Result<Option<PathBuf>> {
|
||||
let Some(exe) = exe else {
|
||||
log::debug!("herdr: process {group}, another user's or gone, does not read");
|
||||
return Ok(None);
|
||||
};
|
||||
if !exe
|
||||
.file_name()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(HERDR))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let (args, env) =
|
||||
launch(group).with_context(|| format!("herdr: process {group} does not read"))?;
|
||||
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)))
|
||||
}
|
||||
|
||||
/// herdr's folder without `XDG_CONFIG_HOME`.
|
||||
pub(super) fn home(env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||
let home = env.get("HOME").context("herdr: its client has no HOME")?;
|
||||
Ok(PathBuf::from(home).join(".config/herdr"))
|
||||
}
|
||||
|
||||
/// herdr's socket, which must answer within `WAIT`.
|
||||
pub(super) fn connect(socket: &Path) -> io::Result<UnixStream> {
|
||||
let stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(WAIT))?;
|
||||
stream.set_write_timeout(Some(WAIT))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn path(pid: Pid) -> io::Result<PathBuf> {
|
||||
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::{ffi::OsStr, 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")]
|
||||
pub(super) fn launch(pid: Pid) -> io::Result<Invocation> {
|
||||
let read = |name: &str| std::fs::read(format!("/proc/{pid}/{name}"));
|
||||
let (args, env) = (read("cmdline")?, read("environ")?);
|
||||
let args = strings(&args).map(lossy).collect();
|
||||
let env = strings(&env).filter_map(variable).collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn cwd(pid: Pid) -> 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]> {
|
||||
bytes
|
||||
.strip_suffix(b"\0")
|
||||
.unwrap_or(bytes)
|
||||
.split(|byte| *byte == 0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn path(pid: Pid) -> io::Result<PathBuf> {
|
||||
let mut buffer = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: the buffer holds the size given.
|
||||
let length =
|
||||
unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
|
||||
if length <= 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
buffer.truncate(length as usize);
|
||||
Ok(PathBuf::from(OsString::from_vec(buffer)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn launch(pid: Pid) -> io::Result<Invocation> {
|
||||
let mut name = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
|
||||
let mut ask = |buffer: &mut Vec<u8>| {
|
||||
let mut size = buffer.len();
|
||||
let into = if buffer.is_empty() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
buffer.as_mut_ptr().cast()
|
||||
};
|
||||
// SAFETY: `into` holds `size` bytes, or is null to ask for the size.
|
||||
let done = unsafe {
|
||||
libc::sysctl(
|
||||
name.as_mut_ptr(),
|
||||
3,
|
||||
into,
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if done != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(size)
|
||||
};
|
||||
let mut buffer = Vec::new();
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.resize(size, 0);
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.truncate(size);
|
||||
procargs(&buffer)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn cwd(pid: Pid) -> 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> {
|
||||
let invalid =
|
||||
|what: &str| io::Error::new(io::ErrorKind::InvalidData, format!("KERN_PROCARGS2 {what}"));
|
||||
let (count, rest) = buffer
|
||||
.split_first_chunk::<4>()
|
||||
.ok_or_else(|| invalid("holds no argument count"))?;
|
||||
let count = usize::try_from(i32::from_ne_bytes(*count))
|
||||
.map_err(|_| invalid("holds a negative argument count"))?;
|
||||
let mut parts = rest
|
||||
.split(|byte| *byte == 0)
|
||||
.skip(1)
|
||||
.skip_while(|part| part.is_empty());
|
||||
let args = parts.by_ref().take(count).map(lossy).collect();
|
||||
let env = parts
|
||||
.take_while(|part| !part.is_empty())
|
||||
.filter_map(variable)
|
||||
.collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
fn lossy(bytes: &[u8]) -> String {
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
|
||||
/// A `NAME=value` variable; its value keeps its bytes.
|
||||
fn variable(pair: &[u8]) -> Option<(String, OsString)> {
|
||||
let at = pair.iter().position(|byte| *byte == b'=')?;
|
||||
Some((
|
||||
lossy(&pair[..at]),
|
||||
OsString::from_vec(pair[at + 1..].to_vec()),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{OsStr, OsString},
|
||||
fs::{File, OpenOptions},
|
||||
io,
|
||||
os::windows::{
|
||||
ffi::{OsStrExt, OsStringExt},
|
||||
fs::OpenOptionsExt,
|
||||
io::{AsRawHandle, FromRawHandle, OwnedHandle},
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{ERROR_NO_MORE_FILES, ERROR_PIPE_BUSY, INVALID_HANDLE_VALUE},
|
||||
Storage::FileSystem::SECURITY_IDENTIFICATION,
|
||||
System::{
|
||||
Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||
TH32CS_SNAPPROCESS,
|
||||
},
|
||||
Pipes::WaitNamedPipeW,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{Invocation, WAIT, follows};
|
||||
use crate::term::Pty;
|
||||
|
||||
pub(super) type Pid = u32;
|
||||
|
||||
/// The herdr processes at or under the session's shell, nearest first.
|
||||
pub(super) type Program = Vec<Pid>;
|
||||
|
||||
/// herdr's executable.
|
||||
const HERDR: &str = "herdr.exe";
|
||||
|
||||
/// The session's shell: Windows has no foreground group, so herdr is sought under it.
|
||||
pub(super) fn leader(pty: &Pty) -> io::Result<Pid> {
|
||||
Ok(pty.pid)
|
||||
}
|
||||
|
||||
pub(super) fn running(shell: Pid) -> io::Result<Program> {
|
||||
Ok(herdrs(&processes()?, shell))
|
||||
}
|
||||
|
||||
/// The herdr processes at or under `root`, breadth first; what a herdr starts, its server among them, is its own.
|
||||
pub(super) fn herdrs(processes: &[(Pid, Pid, String)], root: Pid) -> Vec<Pid> {
|
||||
let herdr = |pid: Pid| {
|
||||
processes
|
||||
.iter()
|
||||
.any(|(id, _, name)| *id == pid && name.eq_ignore_ascii_case(HERDR))
|
||||
};
|
||||
let (mut under, mut found, mut at) = (vec![root], Vec::new(), 0);
|
||||
while let Some(&pid) = under.get(at) {
|
||||
at += 1;
|
||||
if herdr(pid) {
|
||||
found.push(pid);
|
||||
continue;
|
||||
}
|
||||
for (child, ..) in processes.iter().filter(|(_, of, _)| *of == pid) {
|
||||
// A parent id outlives its process, so a stale one may lead back up.
|
||||
if !under.contains(child) {
|
||||
under.push(*child);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Every process: its id, its parent's, and its executable's name.
|
||||
pub(super) fn processes() -> io::Result<Vec<(Pid, Pid, String)>> {
|
||||
// SAFETY: a plain call; its handle is owned below.
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: the handle is new and ours.
|
||||
let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) };
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: size_of::<PROCESSENTRY32W>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut processes = Vec::new();
|
||||
// SAFETY: `entry` holds the size it names.
|
||||
let mut read = unsafe { Process32FirstW(snapshot.as_raw_handle(), &mut entry) };
|
||||
while read != 0 {
|
||||
let name = entry.szExeFile.split(|unit| *unit == 0).next();
|
||||
processes.push((
|
||||
entry.th32ProcessID,
|
||||
entry.th32ParentProcessID,
|
||||
String::from_utf16_lossy(name.unwrap_or_default()),
|
||||
));
|
||||
// SAFETY: as above.
|
||||
read = unsafe { Process32NextW(snapshot.as_raw_handle(), &mut entry) };
|
||||
}
|
||||
let error = io::Error::last_os_error();
|
||||
// The list ends with ERROR_NO_MORE_FILES.
|
||||
if error.raw_os_error() == Some(ERROR_NO_MORE_FILES as i32) {
|
||||
Ok(processes)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket of the herdr server whose terminal client is among `herdrs`; a server or a command is passed by.
|
||||
pub(super) fn client(herdrs: Program) -> anyhow::Result<Option<PathBuf>> {
|
||||
pick(herdrs.into_iter().map(|pid| (pid, launch(pid))))
|
||||
}
|
||||
|
||||
/// The first terminal client's socket in `reads`; with none, a failed read is the error, so it is tried again.
|
||||
pub(super) fn pick(
|
||||
reads: impl IntoIterator<Item = (Pid, io::Result<Invocation>)>,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
let mut unread = None;
|
||||
for (pid, read) in reads {
|
||||
match read.with_context(|| format!("herdr: process {pid} does not read")) {
|
||||
// herdr names its pipe after the socket's path as written, relative or not.
|
||||
Ok((args, env)) => {
|
||||
if let Some(socket) = follows(&args, &env)? {
|
||||
return Ok(Some(socket));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
unread.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
unread.map_or(Ok(None), Err)
|
||||
}
|
||||
|
||||
/// The arguments and variables of process `pid`; names fold to upper case, as Windows matches them.
|
||||
pub(super) fn launch(pid: Pid) -> io::Result<Invocation> {
|
||||
let id = sysinfo::Pid::from_u32(pid);
|
||||
let mut system = System::new();
|
||||
let read = ProcessRefreshKind::nothing()
|
||||
.with_cmd(UpdateKind::Always)
|
||||
.with_environ(UpdateKind::Always);
|
||||
system.refresh_processes_specifics(ProcessesToUpdate::Some(&[id]), true, read);
|
||||
let process = system
|
||||
.process(id)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "the process is gone"))?;
|
||||
// sysinfo leaves a list empty when it does not read; every process has both.
|
||||
if process.cmd().is_empty() || process.environ().is_empty() {
|
||||
return Err(io::Error::other(
|
||||
"its command line or variables do not read",
|
||||
));
|
||||
}
|
||||
let args = process
|
||||
.cmd()
|
||||
.iter()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let env = process
|
||||
.environ()
|
||||
.iter()
|
||||
.filter_map(|pair| variable(pair))
|
||||
.collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
/// A `NAME=value` variable, its name in upper case; its value keeps its units.
|
||||
fn variable(pair: &OsStr) -> Option<(String, OsString)> {
|
||||
let pair: Vec<u16> = pair.encode_wide().collect();
|
||||
let at = pair.iter().position(|unit| *unit == u16::from(b'='))?;
|
||||
Some((
|
||||
String::from_utf16_lossy(&pair[..at]).to_ascii_uppercase(),
|
||||
OsString::from_wide(&pair[at + 1..]),
|
||||
))
|
||||
}
|
||||
|
||||
/// herdr's folder without `XDG_CONFIG_HOME`, as herdr finds it on Windows.
|
||||
pub(super) fn home(env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||
if let Some(data) = env.get("APPDATA") {
|
||||
return Ok(PathBuf::from(data).join("herdr"));
|
||||
}
|
||||
if let Some(profile) = env.get("USERPROFILE") {
|
||||
return Ok(PathBuf::from(profile).join(r"AppData\Roaming\herdr"));
|
||||
}
|
||||
let home = env
|
||||
.get("HOME")
|
||||
.context("herdr: its client has no APPDATA, USERPROFILE or HOME")?;
|
||||
Ok(PathBuf::from(home).join(".config/herdr"))
|
||||
}
|
||||
|
||||
/// The pipe herdr names after its socket's path; a busy one is waited for, as herdr's client waits.
|
||||
pub(super) fn connect(socket: &Path) -> io::Result<File> {
|
||||
let pipe = format!(r"\\.\pipe\{}", socket.to_string_lossy());
|
||||
// Identification only: the pipe's server may not act as this user.
|
||||
let open = || {
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.security_qos_flags(SECURITY_IDENTIFICATION)
|
||||
.open(&pipe)
|
||||
};
|
||||
match open() {
|
||||
Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => {
|
||||
let name: Vec<u16> = OsStr::new(&pipe).encode_wide().chain([0]).collect();
|
||||
// SAFETY: `name` ends with a NUL.
|
||||
if unsafe { WaitNamedPipeW(name.as_ptr(), WAIT.as_millis() as u32) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
open()
|
||||
}
|
||||
opened => opened,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
mod boxes;
|
||||
mod find;
|
||||
pub mod frame;
|
||||
#[cfg(unix)]
|
||||
mod herdr;
|
||||
pub mod ink;
|
||||
pub mod input;
|
||||
|
||||
+7
-3
@@ -66,6 +66,8 @@ pub struct TerminalView {
|
||||
pub(super) held: HashSet<String>,
|
||||
pub(super) finder: Option<Finder>,
|
||||
pub(super) size: (usize, usize),
|
||||
/// How many folders the program has reported.
|
||||
pub(super) reports: u64,
|
||||
cell: (u16, u16),
|
||||
/// Focused in an active window, as the program last heard.
|
||||
focused: bool,
|
||||
@@ -73,7 +75,6 @@ pub struct TerminalView {
|
||||
shown: bool,
|
||||
_events: Task<()>,
|
||||
_blink: Task<()>,
|
||||
#[cfg(unix)]
|
||||
_herdr: Task<()>,
|
||||
_focus: [Subscription; 3],
|
||||
_settings: Subscription,
|
||||
@@ -113,13 +114,13 @@ impl TerminalView {
|
||||
held: HashSet::new(),
|
||||
finder: None,
|
||||
size,
|
||||
reports: 0,
|
||||
cell: (8, 16),
|
||||
focused: false,
|
||||
blinking: false,
|
||||
shown: true,
|
||||
_events: Self::pump(events, cx),
|
||||
_blink: Self::blink(cx),
|
||||
#[cfg(unix)]
|
||||
_herdr: super::herdr::follow(cx),
|
||||
_settings: cx.observe_global::<SettingsStore>(|view, cx| {
|
||||
view.pty
|
||||
@@ -211,7 +212,10 @@ impl TerminalView {
|
||||
.any(|event| matches!(event, PtyEvent::Term(Event::Wakeup)));
|
||||
for event in events {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
|
||||
PtyEvent::Folder(folder) => {
|
||||
self.reports += 1;
|
||||
cx.emit(TerminalEvent::Folder(folder));
|
||||
}
|
||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||
PtyEvent::Mouse(mode) => self.mouse = mode,
|
||||
}
|
||||
|
||||
+10
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
`PRD.md` line by line, as of T23. Status: **Pass** when a test, a run or a review shows it; **Partial** when part of it was shown; **Open** when nothing has shown it yet. Reviews are in `tasks/reviews.md`; tests are named by module (`cargo test -- MODULE`).
|
||||
|
||||
What no check reached: no one has looked at the window on Windows, which CI builds and tests; no real KWin drew the blur; of Wayland desktops, only Hyprland, on the owner's Omarchy, has drawn it. On 2026-10-15 macOS gave `shot` the window's ground alone, for old and new builds alike, while the terminal it ran under lacked Screen Recording (`CGPreflightScreenCaptureAccess` false); that day's macOS checks read the app's log.
|
||||
What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyprland, on the owner's Omarchy, has drawn it. On 2026-10-15 macOS gave `shot` the window's ground alone, for old and new builds alike, while the terminal it ran under lacked Screen Recording (`CGPreflightScreenCaptureAccess` false); that day's macOS checks read the app's log.
|
||||
|
||||
## 1. Product
|
||||
|
||||
@@ -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, 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 |
|
||||
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS, Linux and Windows | T29, T32 | `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; on Windows, `term::herdr` tests on the CI runner: the folder under `APPDATA` and `USERPROFILE`, the `herdr.exe` nearest under the shell, this test's name, arguments and variables read through the live process list and `sysinfo`, a round trip over a named pipe; no real herdr has run on Windows yet | Partial |
|
||||
| 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 |
|
||||
@@ -70,7 +70,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|---|
|
||||
| `ColumnShell` with three columns; blur when the setting is on | T11, T19 | `shot` names a `BlurredView` under the window | Pass |
|
||||
| Columns toggle and keep their state | T11, T23 | a click on each title bar button hid its column and saved it, and a restart opened the window with both hidden (147 columns against 87); Cmd-B with the app active; Ctrl-Shift-B on a real X11 window | Pass |
|
||||
| The window opens at the size it was left at; maximized or full screen, the size before stays; a kept size past the screen shrinks to it | T30 | `a_value_out_of_range_names_its_field`; on X11 under xfwm4: the first start opened at 1200 by 760 and wrote nothing; a resize to 900 by 600 was kept with one write; maximized by xfwm4's Alt+F10 the window spanned the 1400 by 900 screen while the file kept 900 by 600, and restored it was 900 by 600 again; maximized, minimized and restored, the file kept 900 by 600; resized to 950 by 650 and minimized at once, the file kept 900 by 600 while minimized and 950 by 650 once restored; the next start opened at 950 by 650 and wrote nothing; `WM_NORMAL_HINTS` names a 100 by 100 minimum, and a resize to 900 by 50 held at 900 by 100, kept so; a kept 3000 by 2000 opened at 1400 by 900, logged; on macOS, through gpui's own window: a kept 900 by 600 opened at 900 by 600, where a centered edge on a half point had grown it to 601, and a move wrote nothing; a resize to 923 by 617, covered by an opaque window within 150 ms and uncovered, was kept once shown, and the next start opened at 923 by 617; zoomed, minimized, restored, full screen and back, the file kept 923 by 617; a 100 by 100 window was kept; a kept 5000 by 4000 opened at the visible 1920 by 1113, logged; a drag on macOS and any run on Windows not yet shown | Partial |
|
||||
| The window opens at the size it was left at; maximized or full screen, the size before stays; a kept size past the screen shrinks to it | T30 | `a_value_out_of_range_names_its_field`; on X11 under xfwm4: the first start opened at 1200 by 760 and wrote nothing; a resize to 900 by 600 was kept with one write; maximized by xfwm4's Alt+F10 the window spanned the 1400 by 900 screen while the file kept 900 by 600, and restored it was 900 by 600 again; maximized, minimized and restored, the file kept 900 by 600; resized to 950 by 650 and minimized at once, the file kept 900 by 600 while minimized and 950 by 650 once restored; the next start opened at 950 by 650 and wrote nothing; `WM_NORMAL_HINTS` names a 100 by 100 minimum, and a resize to 900 by 50 held at 900 by 100, kept so; a kept 3000 by 2000 opened at 1400 by 900, logged; on macOS, through gpui's own window: a kept 900 by 600 opened at 900 by 600, where a centered edge on a half point had grown it to 601, and a move wrote nothing; a resize to 923 by 617, covered by an opaque window within 150 ms and uncovered, was kept once shown, and the next start opened at 923 by 617; zoomed, minimized, restored, full screen and back, the file kept 923 by 617; a 100 by 100 window was kept; a kept 5000 by 4000 opened at the visible 1920 by 1113, logged; on Windows the owner dragged v0.1.2's window to a new size, closed it, and it opened again at that size; a mouse drag on macOS not yet shown | Pass |
|
||||
| The title shows the session's title, nothing else | T11 | log: "the title is", per session shown | Pass |
|
||||
| The pointer over the terminal is the arrow | T26 | the terminal sets no pointer, so gpui's arrow shows; on X11 with the pointer over the grid, gpui asked for no I-beam ("text"), which it had asked for, and failed to load under Xvfb, before | Pass |
|
||||
| The title bar is transparent | T11, T19 | T19 review | Pass |
|
||||
@@ -149,16 +149,16 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|---|
|
||||
| The icon: Ely, a girl with long pink hair, holding a terminal | T24 | `assets/icons`, drawn at 16 to 1024 px and legible at 32; the owner saw it | Pass |
|
||||
| macOS: the rounded square; Windows and Linux: the figure alone, flat | T24 | `elyterminal-macos.svg` on Apple's grid; `elyterminal.svg`, which the Linux tarball carries and the Windows `.ico` renders | Pass |
|
||||
| `scripts/mac-app.sh` builds the app, signed ad hoc, or for a release with Developer ID and notarized, and its disk image | T24, T26 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal`; for v0.1.1 the Release workflow (run 959) ran it on `macos-signing`: notarization Accepted, the ticket stapled, and on this Mac the downloaded image and its app pass `codesign --verify --strict` under the Developer ID and Gatekeeper's `accepted, source=Notarized Developer ID`; the owner opened it; without the key the script stops before building | Pass |
|
||||
| `scripts/mac-app.sh` builds the app, signed ad hoc, or for a release with Developer ID and notarized, and its disk image | T24, T26, T31 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal`; for v0.1.1 the Release workflow (run 959) ran it on `macos-signing`: notarization Accepted, the ticket stapled, and on this Mac the downloaded image and its app pass `codesign --verify --strict` under the Developer ID and Gatekeeper's `accepted, source=Notarized Developer ID`; the owner opened it; for v0.1.2 the Release workflow (run 1001) ran it again: the downloaded image's stapled ticket validates and Gatekeeper accepts it as `Notarized Developer ID`, and its app, version 0.1.2, passes `codesign --verify --strict --deep` under the Developer ID with the hardened runtime and is accepted the same way; without the key the script stops before building | Pass |
|
||||
|
||||
## 15. Release
|
||||
|
||||
| PRD line | Task | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27 | `v0.1.1` at `43ea5b1`: the zip from its script on the owner's PC, the disk image from the Release workflow, signed and notarized; the Linux tarball, rebuilt for T27 from `aae7fba` by its script on the owner's PC under Omarchy, took the first one's place, and the notes say so; each downloaded back anonymously, once the repository was public, matches the SHA-256 in the release notes. `v0.1.0` at `0075667`, its three files built from that commit by their scripts and checked again: the disk image verifies and its app passes `codesign --verify --strict`; the zip's executable has the icon and no C runtime DLL; the tarball opens its window on Ubuntu 22.04; each file downloaded back matches the SHA-256 in the release notes | Pass |
|
||||
| The Windows executable carries the icon | T25 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen | Pass |
|
||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same; T27's rebuild passes the same checks on Ubuntu 22.04, whose llvmpipe (LLVM 15) leaves the window black in a capture, the first tarball's too, while Debian 13's paints it | Pass |
|
||||
| Each build comes from its script | T25, T27 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL, and for T27 on the owner's PC under Omarchy | Pass |
|
||||
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31 | `v0.1.2` at `4364802`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1001), signed and notarized; published once CI run 1000 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag names `4364802`. `v0.1.1` at `43ea5b1`: the zip from its script on the owner's PC, the disk image from the Release workflow, signed and notarized; the Linux tarball, rebuilt for T27 from `aae7fba` by its script on the owner's PC under Omarchy, took the first one's place, and the notes say so; each downloaded back anonymously, once the repository was public, matches the SHA-256 in the release notes. `v0.1.0` at `0075667`, its three files built from that commit by their scripts and checked again: the disk image verifies and its app passes `codesign --verify --strict`; the zip's executable has the icon and no C runtime DLL; the tarball opens its window on Ubuntu 22.04; each file downloaded back matches the SHA-256 in the release notes | Pass |
|
||||
| The Windows executable carries the icon | T25, T31 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen; v0.1.2's executable the same | Pass |
|
||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same; v0.1.2's passes the same checks on Ubuntu 22.04, run in the owner's WSL, `WM_CLASS` too; T27's rebuild passes the same checks on Ubuntu 22.04, whose llvmpipe (LLVM 15) leaves the window black in a capture, the first tarball's too, while Debian 13's paints it | Pass |
|
||||
| Each build comes from its script | T25, T27, T31 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL, and for T27 on the owner's PC under Omarchy; v0.1.2's three the same way, the disk image's on `macos-signing` | Pass |
|
||||
|
||||
## CI notes
|
||||
|
||||
@@ -188,3 +188,5 @@ On 2026-10-16 the owner ran it on Omarchy 4.0.4 under Hyprland:
|
||||
|
||||
- No window blurred: Omarchy turns Hyprland's own blur off, and Hyprland then blurs nothing; T28 says how to turn it on.
|
||||
- The v0.1.1 Linux tarball built from `aae7fba`, installed by hand, wore the Omarchy theme.
|
||||
|
||||
On 2026-10-16 the owner ran v0.1.2 on Windows: dragged to a new size, closed and opened again, the window opened at that size.
|
||||
@@ -150,3 +150,12 @@ Each task gets at most three codex rounds. Each round: the commit, the verdict,
|
||||
- Round 1, 1bf420c: FAIL. On X11 a maximized window, minimized, wrote the screen's size, since gpui's X11 window calls a maximized window unmaximized while it is hidden; and the window could be dragged under the 100 by 100 the settings keep, so a smaller size was refused and lost. Fixed in 2f58a06: a hidden window keeps the size from before, and the window is no smaller than 100 by 100.
|
||||
- Round 2, 2f58a06: FAIL. A size the window took just before something covered it was skipped while it was hidden and never read again once it showed. Fixed in 9729d24: a window that shows again is read again; and the window opens centered at whole points, since macOS grew a window whose centered edge fell on a half point by a point, so a kept 900 by 600 had opened at 900 by 601.
|
||||
- Round 3, 9729d24: PASS. The reviewer replayed the cover on macOS through gpui and AppKit, against the parent too, and saw the size kept once shown and the restart open at it; the window opened at its kept size and a move wrote nothing; on X11 a maximized, minimized and restored window and a resize minimized at once kept the right size, and the 100 by 100 minimum held. CI runs 993 on 1bf420c and 995 on 2f58a06 passed on macOS, Ubuntu and Windows; run 996 on 9729d24 passed on Ubuntu and Windows.
|
||||
|
||||
## T31
|
||||
|
||||
- Round 1, 4364802 and 2a1060c, with release v0.1.2: PASS. The reviewer compared `Cargo.lock` with its parent, where only elyterminal moved to 0.1.2; matched the 112 files of the source the builds came from to the tag byte for byte; downloaded the three files anonymously against their SHA-256s; checked the disk image's notarization and its app's signature, the zip's icon, manifest and imports, and the tarball's glibc 2.35; and ran the downloaded macOS app with herdr 0.9.5: the folder followed herdr's focus, and the window reopened at its kept 923 by 617. CI run 1000 on 4364802 passed on macOS, Ubuntu and Windows before the release was published; Release run 1001 built the notarized disk image.
|
||||
|
||||
## T32
|
||||
|
||||
- Round 1, d7dc1af: FAIL. On Windows the search took the herdr nearest under the shell, so a server the client started under itself, or one started beside it, was read as the session's herdr; its arguments named no client, nothing was tried again, and the panel stayed where it was. Fixed in 3b5bf46: the herdr processes at or under the shell are listed, none under another herdr, whose server and panes are its own; when that list changes their arguments are read, nearest first, and a server or a command beside the client is passed by.
|
||||
- Round 2, 3b5bf46: PASS. The reviewer ran the committed search and pick against both trees, a nested server, a stale parent loop, an unread process that reads later, a client's start, end and return, and a remote client, eight probes; undid each half of the fix in a copy and saw its regression test fail; and on macOS saw a herdr answer that came 800 ms after the shell's report dropped. CI run 1027 on d7dc1af passed on macOS, Ubuntu and Windows, run 1046 on 3b5bf46 on Ubuntu and Windows, the Windows runner running 120 and then 121 tests; the Linux suite and the real-herdr X11 run passed on both. No real herdr has run on Windows yet.
|
||||
Reference in new issue
Block a user