diff --git a/.github/scripts/bundle-appimage.sh b/.github/scripts/bundle-appimage.sh index 7d51f1a7..03234afe 100755 --- a/.github/scripts/bundle-appimage.sh +++ b/.github/scripts/bundle-appimage.sh @@ -55,6 +55,13 @@ mkdir -p "$APPDIR/usr/bin" cp "target/${TARGET}/release/tty7-app" "$APPDIR/usr/bin/tty7-app" chmod +x "$APPDIR/usr/bin/tty7-app" +# The CLI, beside the GUI as everywhere else. Unlike the tarball, an AppImage is +# mounted at a fresh /tmp/.mount_XXXX per run, so `core::cli_install` must copy +# this onto PATH rather than symlink it — a link into the mount dies the moment +# the app exits. That branch keys off $APPIMAGE, which the runtime sets. +cp "target/${TARGET}/release/tty7" "$APPDIR/usr/bin/tty7" +chmod +x "$APPDIR/usr/bin/tty7" + # A desktop entry + icon are mandatory AppImage metadata; linuxdeploy places # them and generates AppRun. Icon basename must match the desktop's Icon= key. cat > "$TOOLS/tty7.desktop" <<'DESKTOP' diff --git a/.github/scripts/bundle-linux.sh b/.github/scripts/bundle-linux.sh index b9edec67..82e1eef8 100755 --- a/.github/scripts/bundle-linux.sh +++ b/.github/scripts/bundle-linux.sh @@ -29,9 +29,14 @@ mkdir -p "$STAGE" cp "target/${TARGET}/release/tty7-app" "$STAGE/tty7-app" chmod +x "$STAGE/tty7-app" +# The CLI ships beside the GUI, which symlinks it onto PATH at launch (see +# core::cli_install) by resolving it relative to its own executable. +cp "target/${TARGET}/release/tty7" "$STAGE/tty7" +chmod +x "$STAGE/tty7" # Release builds keep symbols (thin LTO, no profile strip); drop them here so # the archive isn't ~100 MB of debug info. strip "$STAGE/tty7-app" || echo "⚠️ strip unavailable — shipping unstripped binary" +strip "$STAGE/tty7" || true mkdir -p "$STAGE/completions" cp assets/completions/*.json "$STAGE/completions/" cp LICENSE "$STAGE/LICENSE" diff --git a/.github/scripts/bundle-macos.sh b/.github/scripts/bundle-macos.sh index 5252e979..650919a8 100755 --- a/.github/scripts/bundle-macos.sh +++ b/.github/scripts/bundle-macos.sh @@ -27,6 +27,13 @@ rm -rf dist mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "target/${TARGET}/release/tty7-app" "$APP/Contents/MacOS/tty7-app" chmod +x "$APP/Contents/MacOS/tty7-app" +# The CLI rides inside the bundle rather than beside it: a DMG is drag-to- +# Applications, so anything not in the .app never reaches the user's disk. The +# GUI symlinks it onto PATH at launch (see core::cli_install), which is why it +# sits next to tty7-app under MacOS/ — that is the directory the GUI resolves +# relative to its own executable. +cp "target/${TARGET}/release/tty7" "$APP/Contents/MacOS/tty7" +chmod +x "$APP/Contents/MacOS/tty7" cp assets/tty7.icns "$APP/Contents/Resources/tty7.icns" # Completion signatures are loaded at runtime (not embedded), resolved relative # to the executable as ../Resources/completions — see terminal::signature. @@ -94,7 +101,17 @@ if [[ -n "$SIGN_ID" && -n "${APPLE_CERTIFICATE:-}" ]]; then ENT - # Sign inner-out: the executable first, then the bundle. + # Sign inner-out: the executables first, then the bundle. The CLI must be + # signed explicitly — notarization rejects a bundle carrying an unsigned + # Mach-O, and the outer `codesign "$APP"` does not descend into MacOS/ for + # anything but CFBundleExecutable. + # + # It gets hardened runtime (notarization requires it) but none of the GUI's + # entitlements: the JIT and library-validation exemptions exist for gpui's + # Metal path, and a CLI that never renders anything has no business holding + # them. + codesign --force --options runtime --timestamp \ + --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7" codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ --sign "$SIGN_ID" "$APP/Contents/MacOS/tty7-app" codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ diff --git a/.github/scripts/bundle-windows.ps1 b/.github/scripts/bundle-windows.ps1 index 24182b03..92a566c9 100644 --- a/.github/scripts/bundle-windows.ps1 +++ b/.github/scripts/bundle-windows.ps1 @@ -21,6 +21,10 @@ Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item "target/$Target/release/tty7-app.exe" "$Stage/tty7-app.exe" +# The CLI, staged beside the GUI so both the zip and the installer carry it. +# `core::cli_install` resolves it relative to tty7-app.exe and puts that +# directory on the user's PATH. +Copy-Item "target/$Target/release/tty7.exe" "$Stage/tty7.exe" New-Item -ItemType Directory -Force -Path "$Stage/completions" | Out-Null Copy-Item "assets/completions/*.json" "$Stage/completions/" Copy-Item LICENSE "$Stage/LICENSE.txt" diff --git a/.github/scripts/windows-installer.iss b/.github/scripts/windows-installer.iss index b7862d2d..387fd655 100644 --- a/.github/scripts/windows-installer.iss +++ b/.github/scripts/windows-installer.iss @@ -58,11 +58,23 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ ; it. The user would keep launching the previous version from their pinned icon, ; against the same daemon endpoint as the new one. Delete it on upgrade; a fresh ; install simply has nothing to remove. +; +; That name is now reused by the CLI below, which lands at the very same path. +; The order saves us: [InstallDelete] runs before [Files], so the stale GUI is +; gone before the CLI is written, and what survives is never a mix of the two. +; The taskbar pin is the loose end — post-upgrade it points at a CLI, so +; clicking it flashes a console instead of opening a window. That is louder +; than the bug it replaces (silently running last release's GUI), and the pin +; is not ours to rewrite; the Start Menu entry in [Icons] is correct either way. [InstallDelete] Type: files; Name: "{app}\tty7.exe" [Files] Source: "{#StageDir}\tty7-app.exe"; DestDir: "{app}"; Flags: ignoreversion +; The CLI. `core::cli_install` adds {app} to the user's PATH at first launch, +; so this is not registered as an [Env] change here — the portable zip has no +; installer to do it, and one code path serving both is one behaviour to debug. +Source: "{#StageDir}\tty7.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#StageDir}\completions\*"; DestDir: "{app}\completions"; Flags: ignoreversion recursesubdirs Source: "{#StageDir}\LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion diff --git a/Cargo.lock b/Cargo.lock index c79c2299..9c27262b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9685,6 +9685,7 @@ dependencies = [ "tray-icon", "tty7-core", "uuid", + "windows-sys 0.61.2", "winresource", ] diff --git a/Cargo.toml b/Cargo.toml index c116f44b..b8d5908a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,6 +146,16 @@ tray-icon = "0.24" [target.'cfg(unix)'.dependencies] libc = "0.2" +# `core::cli_install` edits HKCU\Environment to put the bundled CLI on PATH. +# `setx` is not an option there: it truncates at 1024 characters, so a long user +# PATH comes back mangled. 0.61 is already in the tree (tray-icon pulls it), so +# this pins no new code. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_System_Registry", + "Win32_UI_WindowsAndMessaging", +] } + # Embeds `assets/favicon.ico` into the `.exe` so Windows shows the tty7 logo in # the taskbar / window / Explorer (macOS gets its icon from the `.app` bundle via # bundle.sh instead). Only needed at build time on Windows — see build.rs. diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 36f90ce7..5d622fcb 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -160,6 +160,13 @@ pub struct Config { #[serde(default, deserialize_with = "de_lenient")] pub notify_on_command_finish: NotifyMode, pub check_for_updates: bool, + /// Whether the GUI puts the bundled `tty7` CLI on PATH at launch (see + /// `core::cli_install`). On by default: the CLI is the agent-facing half of + /// this product and is worth nothing sitting unreachable inside the bundle. + /// Off is for people who keep their own `tty7` — a `cargo install` build, a + /// package manager's copy — and do not want it shadowed. + #[serde(default = "default_true")] + pub install_cli_on_path: bool, #[serde(default = "default_notify_threshold_secs")] pub notify_threshold_secs: u64, #[serde(default = "default_true")] @@ -401,6 +408,7 @@ impl Default for Config { sidebar_diff_preview: true, notify_on_command_finish: NotifyMode::Unfocused, check_for_updates: true, + install_cli_on_path: true, notify_threshold_secs: default_notify_threshold_secs(), restore_session: true, show_tray_icon: true, diff --git a/src/core/cli_install.rs b/src/core/cli_install.rs new file mode 100644 index 00000000..3d98c23b --- /dev/null +++ b/src/core/cli_install.rs @@ -0,0 +1,582 @@ +//! Putting the bundled `tty7` CLI on PATH, without asking and without an entry +//! point to click. +//! +//! The GUI and the CLI ship as one artifact but are two binaries: the installer +//! lays `tty7` down beside `tty7-app` (inside `Contents/MacOS/` on macOS, in the +//! install directory elsewhere) and neither one is on PATH by virtue of being +//! there. Rather than a "Install shell command…" menu item that most people +//! never find, the GUI links it up itself on every launch — cheap enough to run +//! unconditionally, idempotent once it has succeeded. +//! +//! Two platform shapes, for reasons that are not symmetric: +//! +//! * **Unix** — symlink the CLI into a directory that is already on PATH. The +//! alternative, putting our own directory on PATH, would mean editing the +//! user's shell rc: a macOS GUI app inherits nothing from the login shell and +//! cannot export into it. Writing to someone's `.zshrc` is a far bigger thing +//! to do unprompted than dropping one symlink. +//! * **Windows** — the reverse. Symlinks need Developer Mode or elevation, and +//! there is no conventional user-writable bin directory on PATH to link into. +//! `HKCU\Environment` is the native answer and needs no privileges. +//! +//! Nothing here is fatal. Every failure path logs and returns; a user whose +//! system resists all of it still has a working GUI, just no `tty7` on PATH. + +use std::path::{Path, PathBuf}; + +/// What a run of [`install`] did, for the log line and for tests. +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + /// Turned off in config. + Disabled, + /// No CLI beside the GUI — a hand-assembled tree, or a stripped bundle. + NoBundledCli, + /// A debug build: panes were wired up, the system was left untouched. + DevBuild, + /// Already reachable as `tty7`, pointing at this install. + AlreadyInstalled(PathBuf), + /// Freshly linked (or copied, under AppImage) into a directory on PATH. + Installed(PathBuf), + /// Installed somewhere the user's PATH does not currently cover. + InstalledOffPath(PathBuf), + /// Something is already called `tty7` on PATH and it is not ours to move. + Occupied(PathBuf), + /// Nowhere to write. + Failed(String), +} + +#[cfg(windows)] +const CLI_NAME: &str = "tty7.exe"; +#[cfg(not(windows))] +const CLI_NAME: &str = "tty7"; + +/// Link the bundled CLI onto PATH, and make it reachable from panes right away. +/// +/// Call this *before* the daemon is spawned: panes inherit their environment +/// from the daemon, which inherits it from this process, so the PATH entry +/// added here reaches every shell opened in this session — including the very +/// first one, and including the case where the on-disk half below fails +/// outright. +pub fn install() -> Outcome { + let outcome = install_inner(); + match &outcome { + Outcome::Disabled | Outcome::NoBundledCli | Outcome::DevBuild => { + log::debug!("cli install skipped: {outcome:?}") + } + Outcome::AlreadyInstalled(p) => log::debug!("tty7 CLI already on PATH at {}", p.display()), + Outcome::Installed(p) => log::info!("put the tty7 CLI on PATH at {}", p.display()), + Outcome::InstalledOffPath(p) => log::warn!( + "installed the tty7 CLI at {}, which is not on your PATH — add it to use `tty7` \ + outside a tty7 pane", + p.display() + ), + Outcome::Occupied(p) => log::info!( + "leaving the existing `tty7` at {} alone; the bundled CLI was not installed", + p.display() + ), + Outcome::Failed(e) => log::warn!("could not put the tty7 CLI on PATH: {e}"), + } + outcome +} + +fn install_inner() -> Outcome { + if !crate::core::config::Config::load().install_cli_on_path { + return Outcome::Disabled; + } + let Some(cli) = bundled_cli() else { + return Outcome::NoBundledCli; + }; + // Panes reach the CLI through the daemon's inherited environment even when + // the on-disk half below is refused, so do this first and unconditionally. + if let Some(dir) = cli.parent() { + prepend_to_process_path(dir); + } + // A dev build gets the environment half and nothing else. `target/debug/` + // holds a `tty7` too, so without this a `cargo run` would point the user's + // real `tty7` at a debug binary — and the isolated instances the dev-verify + // flow spins up would each rewrite the PATH of the machine they are meant + // to be kept away from. Panes still get the build under test, which is the + // half that development actually needs. + if cfg!(debug_assertions) { + return Outcome::DevBuild; + } + platform_install(&cli) +} + +/// The CLI shipped alongside this GUI, if there is one. +/// +/// Resolved relative to the running executable rather than searched for: the +/// point is to install *this build's* CLI, and a PATH search would find +/// whatever is already installed — including the symlink we made last launch, +/// which would then chase its own tail. +fn bundled_cli() -> Option { + let exe = std::env::current_exe().ok()?; + let cli = exe.parent()?.join(CLI_NAME); + // `is_file` and not `exists`: on Unix the answer must be a real binary we + // can exec, and a stale directory of that name should read as "absent". + cli.is_file().then_some(cli) +} + +/// Make the CLI reachable from this process's children (the daemon, and so +/// every pane) without waiting for the on-disk install to take effect. +/// +/// Prepended rather than appended so it wins over a stale copy left on PATH by +/// an older install — inside a tty7 pane, `tty7` should mean the tty7 you are +/// sitting in. +fn prepend_to_process_path(dir: &Path) { + let current = std::env::var_os("PATH").unwrap_or_default(); + let already = std::env::split_paths(¤t).any(|p| p == dir); + if already { + return; + } + let joined = std::iter::once(dir.to_path_buf()) + .chain(std::env::split_paths(¤t)) + .collect::>(); + match std::env::join_paths(joined) { + // SAFETY: single-threaded startup — this runs from `main` before the + // daemon is spawned and before gpui's executor exists, so there is no + // concurrent reader of the environment. + Ok(path) => unsafe { std::env::set_var("PATH", path) }, + Err(e) => log::warn!("could not extend PATH with {}: {e}", dir.display()), + } +} + +// ---- Unix ------------------------------------------------------------------ + +#[cfg(unix)] +fn platform_install(cli: &Path) -> Outcome { + let path_dirs = path_dirs(); + let candidates = candidate_dirs(&path_dirs); + + let mut last_error = None; + for dir in &candidates { + match place(dir, cli) { + Ok(Placement::Already(p)) => return Outcome::AlreadyInstalled(p), + Ok(Placement::Wrote(p)) => { + return if path_dirs.contains(dir) { + Outcome::Installed(p) + } else { + Outcome::InstalledOffPath(p) + }; + } + Ok(Placement::Occupied(p)) => return Outcome::Occupied(p), + Err(e) => last_error = Some(format!("{}: {e}", dir.display())), + } + } + Outcome::Failed(last_error.unwrap_or_else(|| "no writable directory on PATH".into())) +} + +/// The directories worth linking into, best first. +/// +/// Deliberately a fixed list intersected with PATH rather than "the first +/// writable directory on PATH". Version-manager shim directories — pyenv, +/// rbenv, asdf, mise — sit at the *front* of PATH on a great many machines and +/// are writable, which makes them exactly what a first-writable scan picks. A +/// binary dropped there survives until that tool next rehashes and deletes +/// every file it did not put there. The failure is silent and arrives days +/// later, so the safe set is enumerated instead of discovered. +/// +/// `~/.local/bin` is the fallback and is offered even when PATH does not list +/// it: an unreachable install the log names is a better outcome than no install +/// at all, and it is the one directory here we can always create. +#[cfg(unix)] +fn candidate_dirs(path_dirs: &[PathBuf]) -> Vec { + let home = std::env::var_os("HOME").map(PathBuf::from); + let under_home = |rel: &str| home.as_ref().map(|h| h.join(rel)); + + let preferred: Vec = [ + Some(PathBuf::from("/opt/homebrew/bin")), + Some(PathBuf::from("/usr/local/bin")), + under_home(".local/bin"), + under_home("bin"), + under_home(".cargo/bin"), + ] + .into_iter() + .flatten() + .collect(); + + let mut out: Vec = preferred + .iter() + .filter(|d| path_dirs.contains(d)) + .cloned() + .collect(); + if let Some(fallback) = under_home(".local/bin").filter(|f| !out.contains(f)) { + out.push(fallback); + } + out +} + +#[cfg(unix)] +enum Placement { + Already(PathBuf), + Wrote(PathBuf), + Occupied(PathBuf), +} + +/// AppImage mounts itself at a fresh `/tmp/.mount_XXXX` every run, so a symlink +/// into the bundle is dangling the moment the app exits. Copy there instead. +#[cfg(unix)] +fn running_from_appimage() -> bool { + std::env::var_os("APPIMAGE").is_some() +} + +#[cfg(unix)] +fn place(dir: &Path, cli: &Path) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let target = dir.join(CLI_NAME); + + match std::fs::symlink_metadata(&target) { + Ok(meta) if meta.file_type().is_symlink() => { + let points_at = std::fs::read_link(&target)?; + if points_at == cli { + return Ok(Placement::Already(target)); + } + // Replace only a link that is still aimed at something named + // `tty7`. Anything else under this name was pointed somewhere + // deliberate by its owner, and an auto-installer is not the thing + // that gets to overrule that. + if points_at.file_name() != Some(CLI_NAME.as_ref()) { + return Ok(Placement::Occupied(target)); + } + } + // A real file: a `cargo install` build, a package manager's copy, or + // the AppImage copy we made ourselves. Only the last is ours to touch. + Ok(_) => { + if !running_from_appimage() { + return Ok(Placement::Occupied(target)); + } + if same_size(&target, cli) { + return Ok(Placement::Already(target)); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + + write_atomically(dir, &target, cli)?; + Ok(Placement::Wrote(target)) +} + +/// Whether the installed copy already matches, by size alone. +/// +/// Enough for the one case that asks: an AppImage upgrade, where a changed CLI +/// is a different build and a same-size rebuild of the identical source would +/// be a no-op anyway. Hashing megabytes on every launch to sharpen that is not +/// a trade worth making. +#[cfg(unix)] +fn same_size(a: &Path, b: &Path) -> bool { + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(a), Ok(b)) => a.len() == b.len(), + _ => false, + } +} + +/// Write through a temporary name and rename over the target. +/// +/// `std::os::unix::fs::symlink` fails outright if the destination exists, and +/// unlink-then-create leaves a window in which `tty7` resolves to nothing. The +/// rename is atomic, so a concurrent shell either sees the old entry or the new +/// one — never neither. +#[cfg(unix)] +fn write_atomically(dir: &Path, target: &Path, cli: &Path) -> std::io::Result<()> { + // The temp name carries the pid so two tty7 instances starting together + // cannot collide on it. + let tmp = dir.join(format!(".{CLI_NAME}.{}.tmp", std::process::id())); + let _ = std::fs::remove_file(&tmp); + + let result = if running_from_appimage() { + std::fs::copy(cli, &tmp).and_then(|_| { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)) + }) + } else { + std::os::unix::fs::symlink(cli, &tmp) + }; + if let Err(e) = result { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + if let Err(e) = std::fs::rename(&tmp, target) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) +} + +#[cfg(unix)] +fn path_dirs() -> Vec { + std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default() +} + +// ---- Windows --------------------------------------------------------------- + +/// Append the CLI's directory to the *user's* PATH in the registry. +/// +/// Reads `HKCU\Environment` rather than the process PATH on purpose. The +/// process value is the machine and user PATHs already merged, so writing it +/// back into the user hive would copy every system entry into HKCU — the +/// classic way installers permanently corrupt a PATH. +#[cfg(windows)] +fn platform_install(cli: &Path) -> Outcome { + use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _}; + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, + RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE, + }; + + let Some(dir) = cli.parent() else { + return Outcome::Failed("the CLI has no parent directory".into()); + }; + + let wide = |s: &str| { + std::ffi::OsStr::new(s) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>() + }; + + let subkey = wide("Environment"); + let value_name = wide("Path"); + let mut key: HKEY = std::ptr::null_mut(); + + // SAFETY: all pointers below are to live locals, and every out-parameter is + // initialised before the call. The key is closed on every return path. + unsafe { + if RegOpenKeyExW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + 0, + KEY_READ | KEY_WRITE, + &mut key, + ) != 0 + { + return Outcome::Failed("could not open HKCU\\Environment".into()); + } + + // Read the current value. A missing `Path` is normal on a fresh + // profile and means we are writing the first entry, not an error. + let mut kind = 0u32; + let mut bytes = 0u32; + let existing = if RegQueryValueExW( + key, + value_name.as_ptr(), + std::ptr::null_mut(), + &mut kind, + std::ptr::null_mut(), + &mut bytes, + ) == 0 + { + let mut buf = vec![0u16; (bytes as usize).div_ceil(2)]; + if RegQueryValueExW( + key, + value_name.as_ptr(), + std::ptr::null_mut(), + &mut kind, + buf.as_mut_ptr().cast(), + &mut bytes, + ) != 0 + { + RegCloseKey(key); + return Outcome::Failed("could not read the user PATH".into()); + } + // Registry strings may or may not include their NUL. + while buf.last() == Some(&0) { + buf.pop(); + } + std::ffi::OsString::from_wide(&buf) + .to_string_lossy() + .into_owned() + } else { + // Preserve REG_EXPAND_SZ if that is what was there; a fresh value + // is a plain string. + kind = REG_SZ; + String::new() + }; + + let dir_str = dir.to_string_lossy(); + if existing.split(';').any(|e| { + e.trim_end_matches('\\') + .eq_ignore_ascii_case(dir_str.trim_end_matches('\\')) + }) { + RegCloseKey(key); + return Outcome::AlreadyInstalled(cli.to_path_buf()); + } + + let updated = if existing.is_empty() { + dir_str.to_string() + } else { + // Trailing `;` is legal but leaves an empty entry, which some + // tools read as "the current directory" — trim before joining. + format!("{};{dir_str}", existing.trim_end_matches(';')) + }; + let updated_wide = wide(&updated); + + let kind = if kind == REG_EXPAND_SZ { + REG_EXPAND_SZ + } else { + REG_SZ + }; + let written = RegSetValueExW( + key, + value_name.as_ptr(), + 0, + kind, + updated_wide.as_ptr().cast(), + (updated_wide.len() * 2) as u32, + ); + RegCloseKey(key); + if written != 0 { + return Outcome::Failed("could not write the user PATH".into()); + } + + // Without this, only processes started after the next sign-out pick the + // change up: Explorer caches the environment it hands to what it + // launches. The timeout keeps a hung top-level window from stalling + // startup — the write already landed, so this is best-effort. + let env = wide("Environment"); + SendMessageTimeoutW( + HWND_BROADCAST, + WM_SETTINGCHANGE, + 0, + env.as_ptr() as isize, + SMTO_ABORTIFHUNG, + 5_000, + std::ptr::null_mut(), + ); + } + + Outcome::Installed(cli.to_path_buf()) +} + +#[cfg(not(any(unix, windows)))] +fn platform_install(_cli: &Path) -> Outcome { + Outcome::Failed("unsupported platform".into()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + fn touch(p: &Path) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b"#!/bin/sh\n").unwrap(); + } + + fn tmpdir(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("tty7-cli-install-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn a_shim_directory_on_path_is_never_chosen() { + // pyenv's shims are writable and come first on PATH; picking them would + // get the binary deleted on the next rehash. + let home = tmpdir("shims"); + let shims = home.join(".pyenv/shims"); + std::fs::create_dir_all(&shims).unwrap(); + let local = home.join(".local/bin"); + std::fs::create_dir_all(&local).unwrap(); + + let path = vec![shims.clone(), local.clone()]; + let chosen = { + // `candidate_dirs` reads $HOME for the user-relative entries. + let _guard = EnvGuard::set("HOME", &home); + candidate_dirs(&path) + }; + assert!(!chosen.contains(&shims), "shim dir was offered: {chosen:?}"); + assert_eq!(chosen.first(), Some(&local)); + } + + #[test] + fn an_unrelated_binary_named_tty7_is_left_alone() { + let dir = tmpdir("occupied"); + let bin = tmpdir("occupied-src").join("tty7"); + touch(&bin); + // Someone's own build, installed by hand. + touch(&dir.join("tty7")); + + match place(&dir, &bin).unwrap() { + Placement::Occupied(p) => assert_eq!(p, dir.join("tty7")), + other => panic!( + "clobbered a real binary: {:?}", + matches!(other, Placement::Wrote(_)) + ), + } + } + + #[test] + fn our_own_link_is_recognised_and_then_repointed_on_upgrade() { + let dir = tmpdir("relink"); + let v1 = tmpdir("relink-v1").join("tty7"); + let v2 = tmpdir("relink-v2").join("tty7"); + touch(&v1); + touch(&v2); + + assert!(matches!(place(&dir, &v1).unwrap(), Placement::Wrote(_))); + // Second launch, same build: nothing to do. + assert!(matches!(place(&dir, &v1).unwrap(), Placement::Already(_))); + // Upgraded install: the link follows it rather than reporting a clash. + assert!(matches!(place(&dir, &v2).unwrap(), Placement::Wrote(_))); + assert_eq!(std::fs::read_link(dir.join("tty7")).unwrap(), v2); + } + + #[test] + fn a_link_aimed_somewhere_deliberate_is_not_hijacked() { + let dir = tmpdir("deliberate"); + let bin = tmpdir("deliberate-src").join("tty7"); + touch(&bin); + let elsewhere = tmpdir("deliberate-other").join("my-terminal"); + touch(&elsewhere); + std::os::unix::fs::symlink(&elsewhere, dir.join("tty7")).unwrap(); + + assert!(matches!(place(&dir, &bin).unwrap(), Placement::Occupied(_))); + } + + #[test] + fn the_process_path_gains_the_cli_directory_once() { + let dir = tmpdir("procpath"); + let before = std::env::var("PATH").unwrap_or_default(); + prepend_to_process_path(&dir); + let after = std::env::var("PATH").unwrap(); + assert!(after.starts_with(dir.to_str().unwrap()), "{after}"); + + prepend_to_process_path(&dir); + assert_eq!(std::env::var("PATH").unwrap(), after, "added twice"); + // SAFETY: single-threaded test. + unsafe { std::env::set_var("PATH", before) }; + } + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &Path) -> EnvGuard { + let prev = std::env::var_os(key); + // SAFETY: single-threaded test. + unsafe { std::env::set_var(key, value) }; + EnvGuard { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: single-threaded test. + unsafe { + match self.prev.take() { + Some(v) => std::env::set_var(self.key, v), + None => std::env::remove_var(self.key), + } + } + } + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 5e1e5449..22fb526f 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -2,6 +2,7 @@ pub use tty7_core::core::*; pub mod actions; pub mod agent_prompt; +pub mod cli_install; pub mod config; pub mod keychain; pub mod session; diff --git a/src/main.rs b/src/main.rs index 8e2dc575..502af410 100644 --- a/src/main.rs +++ b/src/main.rs @@ -212,6 +212,12 @@ fn main() { #[cfg(unix)] enrich_path_from_login_shell(); + // After the PATH enrichment above, which is what makes the candidate scan + // see the user's real PATH rather than the stub a Finder launch inherits — + // and before the daemon below, which forks every pane and so must already + // carry the CLI's directory in its environment. + crate::core::cli_install::install(); + let restore_session = crate::core::config::Config::load().restore_session; let daemon_result = if restore_session { crate::daemon::spawn::ensure_running()