Add SSH connections: saved hosts manager and hierarchical shell picker
This commit is contained in:
parent
4e5bc7e081
commit
872fb0e80e
14 changed files with 1324 additions and 171 deletions
|
|
@ -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
74
src-tauri/src/hosts.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue