Add SSH connections: saved hosts manager and hierarchical shell picker

This commit is contained in:
megaproxy 2026-05-25 19:47:37 +01:00
parent 4e5bc7e081
commit 872fb0e80e
14 changed files with 1324 additions and 171 deletions

View file

@ -3,7 +3,8 @@
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use tauri::{AppHandle, Manager};
use crate::pty::{list_wsl_distros, PaneId, PtyManager};
use crate::hosts::{self, SshHost};
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
const WORKSPACE_FILE: &str = "workspace.json";
@ -16,14 +17,11 @@ pub async fn list_distros() -> Result<Vec<String>, String> {
pub async fn spawn_pane(
app: AppHandle,
manager: tauri::State<'_, PtyManager>,
distro: Option<String>,
cwd: Option<String>,
spec: SpawnSpec,
cols: u16,
rows: u16,
) -> Result<PaneId, String> {
manager
.spawn_wsl(app, distro, cwd, cols, rows)
.map_err(|e| e.to_string())
manager.spawn(app, spec, cols, rows).map_err(|e| e.to_string())
}
/// `data_b64` is base64-encoded UTF-8 bytes (xterm.js's `onData` emits
@ -92,3 +90,13 @@ pub async fn load_workspace(app: AppHandle) -> Result<Option<String>, String> {
let s = std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
Ok(Some(s))
}
#[tauri::command]
pub async fn list_ssh_hosts(app: AppHandle) -> Result<Vec<SshHost>, String> {
hosts::load(&app).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec<SshHost>) -> Result<(), String> {
crate::hosts::save(&app, &hosts).map_err(|e| e.to_string())
}

74
src-tauri/src/hosts.rs Normal file
View file

@ -0,0 +1,74 @@
//! Saved SSH hosts. Persisted to `%APPDATA%\com.megaproxy.tiletopia\hosts.json`
//! alongside `workspace.json`. The frontend owns the in-memory state and the
//! add/edit/delete UX; the backend just reads/writes the whole list.
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
const HOSTS_FILE: &str = "hosts.json";
/// One saved host. Fields beyond `hostname` are optional; ssh.exe will fall
/// back to `~/.ssh/config` and its own defaults for anything we don't pass.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshHost {
pub id: String,
pub label: String,
pub hostname: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(
default,
rename = "identityFile",
skip_serializing_if = "Option::is_none"
)]
pub identity_file: Option<String>,
#[serde(
default,
rename = "jumpHost",
skip_serializing_if = "Option::is_none"
)]
pub jump_host: Option<String>,
#[serde(
default,
rename = "extraArgs",
skip_serializing_if = "Option::is_none"
)]
pub extra_args: Option<Vec<String>>,
}
fn hosts_path(app: &AppHandle) -> Result<PathBuf> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?;
Ok(dir.join(HOSTS_FILE))
}
pub fn load(app: &AppHandle) -> Result<Vec<SshHost>> {
let path = hosts_path(app)?;
if !path.exists() {
return Ok(Vec::new());
}
let raw = std::fs::read_to_string(&path).context("read hosts.json")?;
let hosts: Vec<SshHost> = serde_json::from_str(&raw).context("parse hosts.json")?;
Ok(hosts)
}
pub fn save(app: &AppHandle, hosts: &[SshHost]) -> Result<()> {
let path = hosts_path(app)?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).context("create_dir_all")?;
}
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_string_pretty(hosts).context("serialize hosts")?;
std::fs::write(&tmp, json.as_bytes()).context("write tmp hosts.json")?;
// `std::fs::rename` is atomic on Unix and uses MoveFileEx with
// REPLACE_EXISTING on Windows — same pattern as save_workspace.
std::fs::rename(&tmp, &path).context("rename hosts.json")?;
Ok(())
}

View file

@ -1,6 +1,7 @@
//! Library entry point. `main.rs` calls `run()`.
mod commands;
mod hosts;
mod pty;
use crate::pty::PtyManager;
@ -26,6 +27,8 @@ pub fn run() {
commands::kill_pane,
commands::save_workspace,
commands::load_workspace,
commands::list_ssh_hosts,
commands::save_ssh_hosts,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -1,6 +1,6 @@
//! PTY backend. Spawns `wsl.exe` (or any command) through portable-pty,
//! reads its output on a background thread, and forwards chunks to the
//! frontend as `pane://{id}/data` events.
//! PTY backend. Spawns a shell (`wsl.exe`, `powershell.exe`, or `ssh.exe`)
//! through portable-pty, reads its output on a background thread, and
//! forwards chunks to the frontend as `pane://{id}/data` events.
use std::collections::HashMap;
use std::io::{Read, Write};
@ -9,16 +9,35 @@ use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use parking_lot::Mutex;
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use serde::Serialize;
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
/// Sentinel "distro" name used to spawn Windows PowerShell instead of WSL.
/// Frontend appends this to the distro list it shows in the dropdown.
pub const POWERSHELL_DISTRO: &str = "PowerShell";
pub type PaneId = u64;
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
/// as `{ kind: "wsl" | "powershell" | "ssh", ... }` from the frontend.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SpawnSpec {
Wsl {
distro: Option<String>,
cwd: Option<String>,
},
Powershell,
Ssh {
host: String,
user: Option<String>,
port: Option<u16>,
#[serde(rename = "identityFile")]
identity_file: Option<String>,
#[serde(rename = "jumpHost")]
jump_host: Option<String>,
#[serde(rename = "extraArgs")]
extra_args: Option<Vec<String>>,
},
}
/// What we keep alive for each spawned PTY.
///
/// `master` stays in scope to keep the PTY alive; we never write through it
@ -45,14 +64,13 @@ impl PtyManager {
}
}
/// Spawn `wsl.exe` (optionally `-d <distro>`, optionally `--cd <cwd>`).
/// Returns the new pane id. A background thread starts reading the PTY
/// immediately and emits `pane://{id}/data` events.
pub fn spawn_wsl(
/// Spawn the shell described by `spec` into a fresh PTY. Returns the
/// new pane id; a background thread immediately starts reading and
/// emits `pane://{id}/data` events.
pub fn spawn(
&self,
app: AppHandle,
distro: Option<String>,
cwd: Option<String>,
spec: SpawnSpec,
cols: u16,
rows: u16,
) -> Result<PaneId> {
@ -66,39 +84,7 @@ impl PtyManager {
})
.context("openpty failed")?;
let is_powershell = distro.as_deref() == Some(POWERSHELL_DISTRO);
let cmd = if is_powershell {
// cwd from the leaf is ignored — leaves may carry Linux-style
// paths (e.g. `~`, `/mnt/d/...`) from a previously-assigned WSL
// distro that PowerShell wouldn't understand. PowerShell starts
// in its own default cwd; user can `cd` if they want.
let mut c = CommandBuilder::new("powershell.exe");
c.arg("-NoLogo");
c
} else {
let mut c = CommandBuilder::new("wsl.exe");
if let Some(d) = distro.as_deref() {
c.arg("-d");
c.arg(d);
}
// Default new panes to the WSL user's home (~) rather than the
// Windows-side cwd we inherit from the launcher (typically
// C:\Users\<you>, which shows up as /mnt/c/Users/<you> inside WSL).
// wsl.exe resolves `~` against the distro's default shell.
let resolved_cwd = cwd.as_deref().unwrap_or("~");
c.arg("--cd");
c.arg(resolved_cwd);
// wsl.exe without an explicit command launches the default shell
// interactively, which is exactly what we want.
c
};
let spawn_err = if is_powershell {
"failed to spawn powershell.exe"
} else {
"failed to spawn wsl.exe; is WSL installed?"
};
let (cmd, spawn_err) = build_command(&spec)?;
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
// We need to keep the master alive (drop = close the PTY), but we
@ -197,6 +183,102 @@ struct DataChunk {
b64: String,
}
// ---- command construction ---------------------------------------------------
/// Reject hostnames / usernames that would let an attacker smuggle in a
/// flag (`-oProxyCommand=...`) or a shell metacharacter via OpenSSH's token
/// expansion. We additionally pass `--` before the host on the command line,
/// but rejecting up front gives a clearer error and avoids ever handing the
/// bad value to ssh.exe.
fn validate_ssh_token(label: &str, value: &str) -> Result<()> {
if value.is_empty() {
return Err(anyhow!("ssh: {label} must not be empty"));
}
if value.starts_with('-') {
return Err(anyhow!("ssh: {label} must not start with '-' (got {value:?})"));
}
if value.chars().any(|c| c.is_control() || c == '\n' || c == '\r') {
return Err(anyhow!("ssh: {label} must not contain control characters"));
}
Ok(())
}
fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> {
match spec {
SpawnSpec::Wsl { distro, cwd } => {
let mut c = CommandBuilder::new("wsl.exe");
if let Some(d) = distro.as_deref() {
c.arg("-d");
c.arg(d);
}
// Default new panes to the WSL user's home (~) rather than the
// Windows-side cwd we inherit from the launcher (typically
// C:\Users\<you>, which shows up as /mnt/c/Users/<you> inside WSL).
// wsl.exe resolves `~` against the distro's default shell.
let resolved_cwd = cwd.as_deref().unwrap_or("~");
c.arg("--cd");
c.arg(resolved_cwd);
Ok((c, "failed to spawn wsl.exe; is WSL installed?"))
}
SpawnSpec::Powershell => {
// cwd intentionally ignored — see commit history.
let mut c = CommandBuilder::new("powershell.exe");
c.arg("-NoLogo");
Ok((c, "failed to spawn powershell.exe"))
}
SpawnSpec::Ssh {
host,
user,
port,
identity_file,
jump_host,
extra_args,
} => {
validate_ssh_token("host", host)?;
if let Some(u) = user.as_deref() {
validate_ssh_token("user", u)?;
}
if let Some(jh) = jump_host.as_deref() {
validate_ssh_token("jump host", jh)?;
}
let mut c = CommandBuilder::new("ssh.exe");
// ssh would auto-detect a tty here, but force it explicitly so
// remote-side TUI apps don't accidentally see a non-tty stdin.
c.arg("-t");
if let Some(u) = user.as_deref() {
c.arg("-l");
c.arg(u);
}
if let Some(p) = port {
c.arg("-p");
c.arg(p.to_string());
}
if let Some(idf) = identity_file.as_deref() {
c.arg("-i");
c.arg(idf);
}
if let Some(jh) = jump_host.as_deref() {
c.arg("-J");
c.arg(jh);
}
if let Some(extra) = extra_args.as_deref() {
for a in extra {
c.arg(a);
}
}
// `--` ends option parsing — a hostname starting with `-` can't
// smuggle in flags via OpenSSH's option parser.
c.arg("--");
c.arg(host);
// Some Windows OpenSSH builds otherwise advertise a TERM the
// remote side doesn't recognise; xterm.js speaks xterm-256color.
c.env("TERM", "xterm-256color");
Ok((c, "failed to spawn ssh.exe; is OpenSSH installed?"))
}
}
}
// ---- distro enumeration -----------------------------------------------------
/// Run a process without flashing a console window on Windows.