Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,14 @@ url = "2.5.8"
rustix = { version = "1.1.4", features = ["event"] }
signal-hook = "0.4.4"

# Console VT input/output modes for ConPTY forwarding and clean stdin-reader
# shutdown after recording; see src/recorder/windows.rs.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Console",
"Win32_System_Threading",
] }

[dev-dependencies]
httpmock = "0.8.3"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ The config file follows the XDG standard and lives at
| Config file key | CLI flag | Meaning |
| ---------------- | ------------------------- | ----------------------------------------------------------------- |
| `outputDir` | | Where to store recording files (defaults to the XDG data dir) |
| `recordingShell` | `-s`, `--shell` | Shell to launch (defaults to `$SHELL`) |
| `recordingShell` | `-s`, `--shell` | Shell to launch (defaults to `$SHELL` on Unix; on Windows, `pwsh` if installed, else `powershell.exe`) |
| `operationSlug` | `--operation` | Operation to upload to (can also be selected before recording) |
| `apiURL` | | Where the ASHIRT backend service is located |
| `accessKey` | | Access Key for the backend (created in the frontend) |
Expand Down
87 changes: 84 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,14 @@ struct ConfigFile {
impl Config {
/// Returns the built-in defaults — the lowest-precedence source.
///
/// Mirrors the Go `TermRecorderConfigWithDefaults`: schema version `1`, the
/// recording shell taken from the `SHELL` environment variable, and the
/// Mirrors the Go `TermRecorderConfigWithDefaults`: schema version `1`, a
/// platform-appropriate recording shell (see [`default_shell`] — `$SHELL` on
/// Unix, PowerShell on Windows, preferring `pwsh` when installed), and the
/// output base defaulting to aterm's per-user XDG data directory.
pub fn with_defaults() -> Self {
Config {
config_version: 1,
recording_shell: std::env::var("SHELL").unwrap_or_default(),
recording_shell: default_shell(),
// Recordings are *data*, so base them under aterm's per-user XDG
// data directory by default (`~/.local/share/aterm` on Linux,
// honouring `$XDG_DATA_HOME`; the platform data dir on Windows;
Expand Down Expand Up @@ -459,6 +460,54 @@ fn default_output_dir() -> String {
}
}

/// Returns the platform-appropriate default recording shell.
///
/// * Unix (Linux/macOS): the user's login shell from `$SHELL`, falling back to
/// `/bin/sh` when it is unset or empty. This is what "pull whatever the
/// `SHELL` environment variable is" means at configuration time.
/// * Windows: PowerShell 7 (`pwsh.exe`) when it is on `PATH`, otherwise Windows
/// PowerShell (`powershell.exe`). Windows has no `$SHELL`, and `%COMSPEC%`
/// points at the legacy `cmd.exe`, so PowerShell is the saner modern default.
/// `pwsh` is preferred because aterm forwards its environment to the child:
/// when launched from `pwsh`, the inherited `PSModulePath` points at
/// PowerShell 7's modules, which breaks module loading (PSReadLine included)
/// in a Windows PowerShell 5.1 child. Launching `pwsh` keeps the shell and its
/// `PSModulePath` consistent; `powershell.exe` is the always-present fallback.
///
/// Always returns a non-empty value, so it doubles as the last-resort fallback
/// when no shell is configured (see [`crate::menu`]).
pub fn default_shell() -> String {
#[cfg(windows)]
{
if executable_on_path("pwsh.exe") {
"pwsh.exe".to_string()
} else {
"powershell.exe".to_string()
}
}
#[cfg(not(windows))]
{
match std::env::var("SHELL") {
Ok(shell) if !shell.trim().is_empty() => shell,
_ => "/bin/sh".to_string(),
}
}
}

/// Returns whether `exe` is found in any directory on `PATH`.
///
/// A minimal `which`-style lookup (no extra dependency) used to prefer
/// `pwsh.exe` over `powershell.exe` only when PowerShell 7 is actually
/// installed. `PATH` is split with the platform separator via
/// [`std::env::split_paths`].
#[cfg(windows)]
fn executable_on_path(exe: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(exe).is_file())
}

/// Returns the platform configuration directory for aterm.
///
/// A plain `aterm` directory under the per-user config base:
Expand Down Expand Up @@ -803,6 +852,38 @@ mod tests {
fs::remove_dir(&dir).ok();
}

/// The default shell is always a non-empty, platform-appropriate value, so
/// `with_defaults` never seeds an empty `recording_shell` and the menu
/// fallback always has something to launch.
#[test]
fn default_shell_is_non_empty_and_platform_appropriate() {
let shell = default_shell();
assert!(!shell.trim().is_empty(), "default shell must be non-empty");

// Windows prefers `pwsh.exe` when on PATH, else `powershell.exe`.
#[cfg(windows)]
assert!(
shell == "pwsh.exe" || shell == "powershell.exe",
"Windows default shell must be pwsh.exe or powershell.exe, got {shell}"
);

// On Unix the default is `$SHELL` when set, else `/bin/sh`.
#[cfg(not(windows))]
match std::env::var("SHELL") {
Ok(s) if !s.trim().is_empty() => assert_eq!(
shell, s,
"Unix default shell must come from $SHELL when set"
),
_ => assert_eq!(
shell, "/bin/sh",
"Unix default shell must fall back to /bin/sh"
),
}

// `with_defaults` seeds the same value.
assert_eq!(Config::with_defaults().recording_shell, shell);
}

#[test]
fn config_path_ends_with_expected_segments() {
// Don't assert the platform prefix, just the trailing structure.
Expand Down
10 changes: 7 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@
//! `anyhow::Result` is used ONLY at the command boundary ([`app::run`] and
//! `main`). See [`config`] for a worked thiserror example and [`app`] for the
//! anyhow boundary.
//! * No `unsafe`: the crate is `#![forbid(unsafe_code)]`. Platform syscalls go
//! through safe wrappers (e.g. `rustix` on Unix).
#![forbid(unsafe_code)]
//! * No `unsafe` by default: the crate is `#![deny(unsafe_code)]`. Platform
//! syscalls go through safe wrappers (e.g. `rustix` on Unix). The one
//! exception is the Windows console FFI in [`recorder::windows`] — VT
//! console-mode setup and cancelling the blocking stdin read — which has no
//! safe-wrapper equivalent and opts in via a scoped, documented
//! `#[allow(unsafe_code)]` on that module alone.
#![deny(unsafe_code)]

pub mod app;
pub mod asciicast;
Expand Down
13 changes: 6 additions & 7 deletions src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,17 +666,16 @@ fn start_recording(
Ok(())
}

/// Picks the shell to record: the configured shell, falling back to `$SHELL`,
/// then `/bin/sh`. The configured value is normally already seeded from `$SHELL`
/// by [`Config::with_defaults`]; this keeps a sensible last resort.
/// Picks the shell to record: the configured shell, falling back to the
/// platform default ([`crate::config::default_shell`] — `$SHELL` on Unix,
/// PowerShell on Windows). The configured value is normally already seeded with
/// that same default by [`Config::with_defaults`]; this keeps a sensible last
/// resort when the config carries an empty shell.
fn recording_shell(config: &Config) -> String {
if !config.recording_shell.trim().is_empty() {
return config.recording_shell.clone();
}
match std::env::var("SHELL") {
Ok(shell) if !shell.trim().is_empty() => shell,
_ => "/bin/sh".to_string(),
}
crate::config::default_shell()
}

#[cfg(test)]
Expand Down
9 changes: 9 additions & 0 deletions src/recorder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ impl PtySession {
.map_err(|e| RecorderError::OpenPty(e.to_string()))?;

let mut cmd = CommandBuilder::new(shell);
// Forward the current environment verbatim. `std::env::vars()` only yields
// variables that are actually set, so `TERM`/`SHELL` are passed through
// when present (e.g. a Git Bash / MSYS / WSL-interop launch) and simply
// absent otherwise — native Windows consoles define neither.
for (key, value) in std::env::vars() {
cmd.env(key, value);
}
Expand Down Expand Up @@ -454,6 +458,11 @@ pub fn record_session<W: Write + Send + 'static>(
// Raw mode is entered only after the PTY is up so an early failure leaves the
// terminal untouched. The guard restores cooked mode on any exit path.
let _raw = RawModeGuard::enable()?;
// Platform terminal-mode setup, restored on drop: on Windows this enables the
// console's virtual-terminal input/output so host key presses reach the
// ConPTY child as VT sequences and its VT output renders; on Unix it is a
// no-op (the PTY already does this). Dropped after teardown, before the menu.
let _term_mode = platform::TerminalModeGuard::install()?;
let mut resize_watcher = platform::ResizeWatcher::install()?;
let mut stdin_poller = platform::StdinPoller::new()?;

Expand Down
18 changes: 15 additions & 3 deletions src/recorder/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,21 @@ use rustix::io::Errno;

use super::StdinRead;

/// Returns the user's preferred shell (`$SHELL`), falling back to `/bin/sh`.
pub fn default_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
/// No-op terminal-mode guard on Unix.
///
/// Unix PTYs already deliver raw VT input and render VT output without any extra
/// console-mode setup, so the cooked-mode clearing done by the shared
/// [`RawModeGuard`](super::RawModeGuard) is sufficient. This type exists only so
/// the shared recorder can install platform terminal-mode setup uniformly; its
/// Windows counterpart configures the console's virtual-terminal modes.
#[must_use = "kept symmetric with the Windows guard; bind it to a variable"]
pub struct TerminalModeGuard;

impl TerminalModeGuard {
/// Installs nothing; succeeds unconditionally.
pub fn install() -> io::Result<Self> {
Ok(Self)
}
}

/// Detects terminal resizes via the `SIGWINCH` signal.
Expand Down
Loading