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
2 changes: 1 addition & 1 deletion apps/knock-app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ toml = { workspace = true }
base64 = { workspace = true }
dirs = { workspace = true }
rand = { workspace = true }
portable-pty = "0.8"
portable-pty = "0.9"
uuid = { version = "1", features = ["v4"] }
reqwest = { workspace = true }
url = "2"
Expand Down
22 changes: 16 additions & 6 deletions apps/knock-app/src-tauri/src/kubeconfig_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub struct KubeEntryDto {
#[serde(rename_all = "camelCase")]
pub struct KubeSettingsDto {
pub preferred_terminal: String,
pub preferred_shell: String,
}

/// Cache of decrypted temp paths (and launcher scripts).
Expand Down Expand Up @@ -225,15 +226,24 @@ pub fn kubeconfig_settings_get() -> Result<KubeSettingsDto, String> {
let s = kubeconfigs::read_settings(&dir).map_err(map_err)?;
Ok(KubeSettingsDto {
preferred_terminal: s.preferred_terminal,
preferred_shell: s.preferred_shell,
})
}

#[tauri::command]
pub fn kubeconfig_settings_set(preferred_terminal: String) -> Result<(), String> {
pub fn kubeconfig_settings_set(
preferred_terminal: Option<String>,
preferred_shell: Option<String>,
) -> Result<(), String> {
let dir = kubeconfigs::default_store_dir().map_err(map_err)?;
kubeconfigs::write_settings(
&dir,
&kubeconfigs::KubeconfigSettings { preferred_terminal },
)
.map_err(map_err)
// Merge over the stored settings so a caller can update one field without
// clobbering the other.
let mut s = kubeconfigs::read_settings(&dir).map_err(map_err)?;
if let Some(t) = preferred_terminal {
s.preferred_terminal = t;
}
if let Some(sh) = preferred_shell {
s.preferred_shell = sh;
}
kubeconfigs::write_settings(&dir, &s).map_err(map_err)
}
1 change: 1 addition & 0 deletions apps/knock-app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ pub fn run() {
kubeconfig_cmd::kubeconfig_settings_set,
terminal_cmd::kubeconfig_list_terminals,
terminal_cmd::kubeconfig_open_terminal,
terminal_cmd::terminal_list_shells,
terminal_cmd::terminal_spawn,
terminal_cmd::terminal_write,
terminal_cmd::terminal_resize,
Expand Down
193 changes: 184 additions & 9 deletions apps/knock-app/src-tauri/src/terminal_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,153 @@ fn default_shell() -> String {
}
}

/// A shell the embedded PTY can launch. `id` is a stable key; the backend maps
/// it to a concrete program + args in `shell_command`.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShellInfo {
pub id: String,
pub label: String,
pub available: bool,
}

#[cfg(windows)]
fn windows_git_bash() -> Option<PathBuf> {
// git-bash ships bash.exe under <Git>\bin\bash.exe. Probe PATH then the
// standard install locations.
if let Some(p) = which_win("bash.exe") {
return Some(p);
}
for base in [
std::env::var("ProgramFiles").ok(),
std::env::var("ProgramFiles(x86)").ok(),
std::env::var("LocalAppData").ok(),
]
.into_iter()
.flatten()
{
let p = PathBuf::from(base).join("Git").join("bin").join("bash.exe");
if p.exists() {
return Some(p);
}
}
None
}

#[cfg(windows)]
const WINDOWS_SHELLS: &[(&str, &str)] = &[
("cmd", "Command Prompt"),
("powershell", "Windows PowerShell"),
("pwsh", "PowerShell 7"),
("git-bash", "Git Bash"),
("wsl", "WSL"),
];

#[cfg(unix)]
const UNIX_SHELLS: &[(&str, &str)] = &[("zsh", "zsh"), ("bash", "bash"), ("fish", "fish")];

fn shell_available(id: &str) -> bool {
#[cfg(windows)]
{
match id {
"cmd" => true,
"powershell" => which_win("powershell.exe").is_some(),
"pwsh" => which_win("pwsh.exe").is_some(),
"git-bash" => windows_git_bash().is_some(),
"wsl" => which_win("wsl.exe").is_some(),
_ => false,
}
}
#[cfg(unix)]
{
// Probe both common bin dirs without requiring `which`.
let candidates = [
format!("/bin/{id}"),
format!("/usr/bin/{id}"),
format!("/usr/local/bin/{id}"),
format!("/opt/homebrew/bin/{id}"),
];
candidates.iter().any(|p| Path::new(p).exists())
}
}

#[tauri::command]
pub fn terminal_list_shells() -> Vec<ShellInfo> {
let mut out = vec![ShellInfo {
id: "auto".to_string(),
label: "Default shell".to_string(),
available: true,
}];
#[cfg(windows)]
{
for (id, label) in WINDOWS_SHELLS {
out.push(ShellInfo {
id: (*id).to_string(),
label: (*label).to_string(),
available: shell_available(id),
});
}
}
#[cfg(unix)]
{
for (id, label) in UNIX_SHELLS {
out.push(ShellInfo {
id: (*id).to_string(),
label: (*label).to_string(),
available: shell_available(id),
});
}
}
out
}

/// Build the `(program, args)` for a requested shell id. Falls back to the
/// platform default when `id` is "auto"/empty/unknown or the shell is missing.
fn shell_command(id: &str) -> (String, Vec<String>) {
let id = id.trim();
#[cfg(windows)]
{
match id {
"powershell" if shell_available("powershell") => {
("powershell.exe".to_string(), vec!["-NoLogo".to_string()])
}
"pwsh" if shell_available("pwsh") => {
("pwsh.exe".to_string(), vec!["-NoLogo".to_string()])
}
"git-bash" => {
if let Some(bash) = windows_git_bash() {
return (bash.to_string_lossy().to_string(), vec!["-l".to_string()]);
}
(default_shell(), vec![])
}
"wsl" if shell_available("wsl") => ("wsl.exe".to_string(), vec![]),
"cmd" => (
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()),
vec![],
),
_ => (default_shell(), vec![]),
}
}
#[cfg(unix)]
{
let login = vec!["-l".to_string()];
match id {
"zsh" if shell_available("zsh") => ("zsh".to_string(), login),
"bash" if shell_available("bash") => ("bash".to_string(), login),
"fish" if shell_available("fish") => ("fish".to_string(), vec!["-l".to_string()]),
_ => {
let sh = default_shell();
let args = if sh.ends_with("zsh") || sh.ends_with("bash") {
vec!["-l".to_string()]
} else {
vec![]
};
(sh, args)
}
}
}
}

#[tauri::command]
pub fn terminal_spawn(
app: AppHandle,
Expand All @@ -498,6 +645,7 @@ pub fn terminal_spawn(
cwd: Option<String>,
cols: Option<u16>,
rows: Option<u16>,
shell: Option<String>,
) -> Result<String, String> {
let kubeconfig_path = if let Some(name) = name.filter(|s| !s.is_empty()) {
let project = project_or_default(project);
Expand All @@ -517,13 +665,21 @@ pub fn terminal_spawn(
})
.map_err(|e| e.to_string())?;

let shell = default_shell();
let mut cmd = CommandBuilder::new(&shell);
#[cfg(unix)]
{
if shell.ends_with("zsh") || shell.ends_with("bash") {
cmd.arg("-l");
}
// Explicit `shell` wins; otherwise fall back to the persisted preference.
let shell_id = shell
.filter(|s| !s.is_empty() && s != "auto")
.or_else(|| {
kubeconfigs::default_store_dir()
.ok()
.and_then(|dir| kubeconfigs::read_settings(&dir).ok())
.map(|s| s.preferred_shell)
.filter(|s| !s.is_empty() && s != "auto")
})
.unwrap_or_else(|| "auto".to_string());
let (program, args) = shell_command(&shell_id);
let mut cmd = CommandBuilder::new(&program);
for a in &args {
cmd.arg(a);
}
if let Some(kubeconfig_path) = kubeconfig_path {
cmd.env("KUBECONFIG", &kubeconfig_path);
Expand Down Expand Up @@ -567,12 +723,31 @@ pub fn terminal_spawn(
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(0) => break, // EOF: PTY closed, process tree exited
Ok(n) => {
let chunk = base64::engine::general_purpose::STANDARD.encode(&buf[..n]);
let _ = app_for_reader.emit(&event_name, chunk);
}
Err(_) => break,
// Transient errors must NOT kill the stream. On Windows ConPTY a
// chatty process (e.g. `next start` flooding logs) can surface
// Interrupted/WouldBlock/spurious errors mid-stream; breaking here
// froze the terminal. Retry on those; only a true disconnect ends it.
Err(e) => match e.kind() {
std::io::ErrorKind::Interrupted => continue,
std::io::ErrorKind::WouldBlock => {
// Reader is blocking by default; if the OS ever returns
// this, sleep to avoid a busy spin.
std::thread::sleep(std::time::Duration::from_millis(5));
continue;
}
std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::UnexpectedEof => break,
_ => {
// Unknown error: yield briefly and retry rather than
// tearing down a live session.
std::thread::sleep(std::time::Duration::from_millis(10));
continue;
}
},
}
}
let _ = app_for_reader.emit(&exit_event, ());
Expand Down
Loading
Loading