Skip to content

Commit 73d93dc

Browse files
committed
Platform-aware default shell + Windows recording fixes
Default recording shell is now platform-aware (config::default_shell), used by both Config::with_defaults() and the menu's runtime fallback: * Unix: $SHELL, falling back to /bin/sh. * Windows: pwsh.exe when PowerShell 7 is on PATH, else powershell.exe. Previously with_defaults() read $SHELL on every platform, so Windows got an empty default and fell back to /bin/sh, which does not exist there. pwsh is preferred because aterm forwards its environment to the child: launched from pwsh, the inherited PSModulePath points at PowerShell 7's modules, and handing that to a Windows PowerShell 5.1 child makes it load PS7 module versions incompatible with 5.1 — PSReadLine and even core modules fail to load ("Cannot load PSReadline module"). Launching pwsh keeps the shell and its PSModulePath consistent. The unused per-platform default_shell() helpers are removed. Windows console handling for the recording (no-op on Unix): * TerminalModeGuard enables ENABLE_VIRTUAL_TERMINAL_INPUT (and clears the cooked-input flags) so special keys — arrows, Home/End, function keys — are captured as VT escape sequences and forwarded to the child; ReadConsoleW otherwise returns only typed characters and drops them. It also enables ENABLE_VIRTUAL_TERMINAL_PROCESSING + DISABLE_NEWLINE_AUTO_RETURN so the child's VT output (colours, cursor motion) renders instead of printing escape bytes. The exact prior console modes are restored on drop. * StdinPoller's reader thread gates each blocking console read behind a 50ms WaitForSingleObject on the console input handle, so it re-checks its stop flag and exits on its own at teardown. This keeps the thread from outliving the recording and stealing the post-recording menu's first keystroke, and avoids an un-interruptible read that would otherwise hang the menu until a keypress. These Windows console APIs have no safe-wrapper equivalent (the way rustix covers the Unix syscalls), so the crate-wide forbid(unsafe_code) is relaxed to deny(unsafe_code) with a single scoped, documented #[allow(unsafe_code)] on recorder::windows; each FFI call has a SAFETY comment. Adds windows-sys as a cfg(windows) dependency. Verified: 164 tests pass; clippy clean on the native and x86_64-pc-windows-gnu targets. Windows runtime behavior confirmed by the reporter (PSReadLine loads, special keys work, exit drops straight to the menu).
1 parent 7c5d80f commit 73d93dc

9 files changed

Lines changed: 305 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,14 @@ url = "2.5.8"
3737
rustix = { version = "1.1.4", features = ["event"] }
3838
signal-hook = "0.4.4"
3939

40+
# Console VT input/output modes for ConPTY forwarding and clean stdin-reader
41+
# shutdown after recording; see src/recorder/windows.rs.
42+
[target.'cfg(windows)'.dependencies]
43+
windows-sys = { version = "0.61", features = [
44+
"Win32_Foundation",
45+
"Win32_System_Console",
46+
"Win32_System_Threading",
47+
] }
48+
4049
[dev-dependencies]
4150
httpmock = "0.8.3"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ The config file follows the XDG standard and lives at
7575
| Config file key | CLI flag | Meaning |
7676
| ---------------- | ------------------------- | ----------------------------------------------------------------- |
7777
| `outputDir` | | Where to store recording files (defaults to the XDG data dir) |
78-
| `recordingShell` | `-s`, `--shell` | Shell to launch (defaults to `$SHELL`) |
78+
| `recordingShell` | `-s`, `--shell` | Shell to launch (defaults to `$SHELL` on Unix; on Windows, `pwsh` if installed, else `powershell.exe`) |
7979
| `operationSlug` | `--operation` | Operation to upload to (can also be selected before recording) |
8080
| `apiURL` | | Where the ASHIRT backend service is located |
8181
| `accessKey` | | Access Key for the backend (created in the frontend) |

src/config.rs

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,14 @@ struct ConfigFile {
157157
impl Config {
158158
/// Returns the built-in defaults — the lowest-precedence source.
159159
///
160-
/// Mirrors the Go `TermRecorderConfigWithDefaults`: schema version `1`, the
161-
/// recording shell taken from the `SHELL` environment variable, and the
160+
/// Mirrors the Go `TermRecorderConfigWithDefaults`: schema version `1`, a
161+
/// platform-appropriate recording shell (see [`default_shell`] — `$SHELL` on
162+
/// Unix, PowerShell on Windows, preferring `pwsh` when installed), and the
162163
/// output base defaulting to aterm's per-user XDG data directory.
163164
pub fn with_defaults() -> Self {
164165
Config {
165166
config_version: 1,
166-
recording_shell: std::env::var("SHELL").unwrap_or_default(),
167+
recording_shell: default_shell(),
167168
// Recordings are *data*, so base them under aterm's per-user XDG
168169
// data directory by default (`~/.local/share/aterm` on Linux,
169170
// honouring `$XDG_DATA_HOME`; the platform data dir on Windows;
@@ -459,6 +460,54 @@ fn default_output_dir() -> String {
459460
}
460461
}
461462

463+
/// Returns the platform-appropriate default recording shell.
464+
///
465+
/// * Unix (Linux/macOS): the user's login shell from `$SHELL`, falling back to
466+
/// `/bin/sh` when it is unset or empty. This is what "pull whatever the
467+
/// `SHELL` environment variable is" means at configuration time.
468+
/// * Windows: PowerShell 7 (`pwsh.exe`) when it is on `PATH`, otherwise Windows
469+
/// PowerShell (`powershell.exe`). Windows has no `$SHELL`, and `%COMSPEC%`
470+
/// points at the legacy `cmd.exe`, so PowerShell is the saner modern default.
471+
/// `pwsh` is preferred because aterm forwards its environment to the child:
472+
/// when launched from `pwsh`, the inherited `PSModulePath` points at
473+
/// PowerShell 7's modules, which breaks module loading (PSReadLine included)
474+
/// in a Windows PowerShell 5.1 child. Launching `pwsh` keeps the shell and its
475+
/// `PSModulePath` consistent; `powershell.exe` is the always-present fallback.
476+
///
477+
/// Always returns a non-empty value, so it doubles as the last-resort fallback
478+
/// when no shell is configured (see [`crate::menu`]).
479+
pub fn default_shell() -> String {
480+
#[cfg(windows)]
481+
{
482+
if executable_on_path("pwsh.exe") {
483+
"pwsh.exe".to_string()
484+
} else {
485+
"powershell.exe".to_string()
486+
}
487+
}
488+
#[cfg(not(windows))]
489+
{
490+
match std::env::var("SHELL") {
491+
Ok(shell) if !shell.trim().is_empty() => shell,
492+
_ => "/bin/sh".to_string(),
493+
}
494+
}
495+
}
496+
497+
/// Returns whether `exe` is found in any directory on `PATH`.
498+
///
499+
/// A minimal `which`-style lookup (no extra dependency) used to prefer
500+
/// `pwsh.exe` over `powershell.exe` only when PowerShell 7 is actually
501+
/// installed. `PATH` is split with the platform separator via
502+
/// [`std::env::split_paths`].
503+
#[cfg(windows)]
504+
fn executable_on_path(exe: &str) -> bool {
505+
let Some(path) = std::env::var_os("PATH") else {
506+
return false;
507+
};
508+
std::env::split_paths(&path).any(|dir| dir.join(exe).is_file())
509+
}
510+
462511
/// Returns the platform configuration directory for aterm.
463512
///
464513
/// A plain `aterm` directory under the per-user config base:
@@ -803,6 +852,38 @@ mod tests {
803852
fs::remove_dir(&dir).ok();
804853
}
805854

855+
/// The default shell is always a non-empty, platform-appropriate value, so
856+
/// `with_defaults` never seeds an empty `recording_shell` and the menu
857+
/// fallback always has something to launch.
858+
#[test]
859+
fn default_shell_is_non_empty_and_platform_appropriate() {
860+
let shell = default_shell();
861+
assert!(!shell.trim().is_empty(), "default shell must be non-empty");
862+
863+
// Windows prefers `pwsh.exe` when on PATH, else `powershell.exe`.
864+
#[cfg(windows)]
865+
assert!(
866+
shell == "pwsh.exe" || shell == "powershell.exe",
867+
"Windows default shell must be pwsh.exe or powershell.exe, got {shell}"
868+
);
869+
870+
// On Unix the default is `$SHELL` when set, else `/bin/sh`.
871+
#[cfg(not(windows))]
872+
match std::env::var("SHELL") {
873+
Ok(s) if !s.trim().is_empty() => assert_eq!(
874+
shell, s,
875+
"Unix default shell must come from $SHELL when set"
876+
),
877+
_ => assert_eq!(
878+
shell, "/bin/sh",
879+
"Unix default shell must fall back to /bin/sh"
880+
),
881+
}
882+
883+
// `with_defaults` seeds the same value.
884+
assert_eq!(Config::with_defaults().recording_shell, shell);
885+
}
886+
806887
#[test]
807888
fn config_path_ends_with_expected_segments() {
808889
// Don't assert the platform prefix, just the trailing structure.

src/lib.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@
1212
//! `anyhow::Result` is used ONLY at the command boundary ([`app::run`] and
1313
//! `main`). See [`config`] for a worked thiserror example and [`app`] for the
1414
//! anyhow boundary.
15-
//! * No `unsafe`: the crate is `#![forbid(unsafe_code)]`. Platform syscalls go
16-
//! through safe wrappers (e.g. `rustix` on Unix).
17-
#![forbid(unsafe_code)]
15+
//! * No `unsafe` by default: the crate is `#![deny(unsafe_code)]`. Platform
16+
//! syscalls go through safe wrappers (e.g. `rustix` on Unix). The one
17+
//! exception is the Windows console FFI in [`recorder::windows`] — VT
18+
//! console-mode setup and cancelling the blocking stdin read — which has no
19+
//! safe-wrapper equivalent and opts in via a scoped, documented
20+
//! `#[allow(unsafe_code)]` on that module alone.
21+
#![deny(unsafe_code)]
1822

1923
pub mod app;
2024
pub mod asciicast;

src/menu.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -666,17 +666,16 @@ fn start_recording(
666666
Ok(())
667667
}
668668

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

682681
#[cfg(test)]

src/recorder/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ impl PtySession {
131131
.map_err(|e| RecorderError::OpenPty(e.to_string()))?;
132132

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

src/recorder/unix.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,21 @@ use rustix::io::Errno;
1818

1919
use super::StdinRead;
2020

21-
/// Returns the user's preferred shell (`$SHELL`), falling back to `/bin/sh`.
22-
pub fn default_shell() -> String {
23-
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
21+
/// No-op terminal-mode guard on Unix.
22+
///
23+
/// Unix PTYs already deliver raw VT input and render VT output without any extra
24+
/// console-mode setup, so the cooked-mode clearing done by the shared
25+
/// [`RawModeGuard`](super::RawModeGuard) is sufficient. This type exists only so
26+
/// the shared recorder can install platform terminal-mode setup uniformly; its
27+
/// Windows counterpart configures the console's virtual-terminal modes.
28+
#[must_use = "kept symmetric with the Windows guard; bind it to a variable"]
29+
pub struct TerminalModeGuard;
30+
31+
impl TerminalModeGuard {
32+
/// Installs nothing; succeeds unconditionally.
33+
pub fn install() -> io::Result<Self> {
34+
Ok(Self)
35+
}
2436
}
2537

2638
/// Detects terminal resizes via the `SIGWINCH` signal.

0 commit comments

Comments
 (0)