Files
nexus/rust/app/src/forwards.rs
T
Andy Dixon 9805681c2c license: release Nexus Terminal under the GNU AGPL v3
- LICENSE: the GNU Affero General Public License, version 3 (verbatim
  from gnu.org).
- SPDX-License-Identifier: AGPL-3.0-only plus a copyright line on every
  first-party source file (Rust, shell, Python, PHP); vendored crates keep
  their own licenses.
- Cargo manifests carry license/authors/repository; the macOS bundle
  copyright and the About page state the license and link the source.
- Release zips (and the macOS app bundle) include LICENSE.
- README: License section.
2026-10-13 09:46:31 +01:00

171 lines
6.9 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Andy Dixon
//! Port forwards dialog for a live SSH session: list, stop, add.
use crate::chrome::modal;
use crate::state::Shared;
use crate::theme::{self, Tokens};
use crate::widgets;
use egui::{Align, Key, Layout, RichText};
use egui_phosphor::regular as icon;
use nexus_core::models::{ForwardDirection, PortForward, new_id};
use parking_lot::Mutex;
use std::sync::Arc;
struct Form {
remote: bool,
bind_host: String,
bind_port: String,
dest_host: String,
dest_port: String,
label: String,
}
impl Default for Form {
fn default() -> Form {
Form {
remote: false,
bind_host: "127.0.0.1".into(),
bind_port: String::new(),
dest_host: "localhost".into(),
dest_port: String::new(),
label: String::new(),
}
}
}
#[derive(Default)]
pub struct Forwards {
session: Option<String>,
form: Form,
/// Worker results: true = a forward started, false = one stopped.
done: Arc<Mutex<Vec<bool>>>,
}
fn port(s: &str) -> Option<i64> {
s.trim().parse::<u16>().ok().filter(|&p| p > 0).map(i64::from)
}
impl Forwards {
pub fn open(&mut self, session: &str) {
self.session = Some(session.into());
}
pub fn ui(&mut self, ctx: &egui::Context, s: &mut Shared) {
for started in std::mem::take(&mut *self.done.lock()) {
if started {
s.success("Forward started");
self.form.bind_port.clear();
self.form.dest_port.clear();
self.form.label.clear();
} else {
s.info("Forward stopped");
}
}
let Some(sid) = self.session.clone() else {
return;
};
// A lock and a clone in the client; no network.
let list = match s.svc.sessions.forwards(&sid) {
Ok(l) => l,
Err(_) => {
s.error("Port forwarding is only available for SSH sessions");
self.session = None;
return;
}
};
let t = Tokens::get(ctx);
let mut keep = !crate::chrome::modal_key(ctx, Key::Escape);
let mut stop = None;
let mut start = false;
let f = &mut self.form;
modal(ctx, "port-forwards", 600.0, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} Port Forwards", icon::ARROWS_LEFT_RIGHT)).font(theme::semibold(15.0)));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::icon_button(ui, icon::X, "Close").clicked() {
keep = false;
}
});
});
ui.add_space(4.0);
if list.is_empty() {
widgets::hint(ui, "No active port forwards");
}
for fwd in &list {
ui.horizontal(|ui| {
let local = fwd.direction == ForwardDirection::Local;
widgets::chip(ui, if local { "L" } else { "R" }, if local { t.accent } else { t.warning });
let or = |s: &str, d: &str| {
if s.is_empty() { d.to_string() } else { s.to_string() }
};
let text = format!("{}:{} → {}:{}", or(&fwd.bind_host, "127.0.0.1"), fwd.bind_port, or(&fwd.dest_host, "localhost"), fwd.dest_port);
ui.label(RichText::new(text).font(theme::mono(12.0)));
if !fwd.label.is_empty() {
ui.label(RichText::new(&fwd.label).size(11.5).color(t.text_faint));
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(egui::Button::new(RichText::new(format!("{} Stop", icon::STOP)).color(t.accent_text)).fill(t.danger)).clicked() {
stop = Some(fwd.id.clone());
}
});
});
}
ui.separator();
widgets::section(ui, "Add new forward");
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("pf-dir").width(96.0).selected_text(if f.remote { "Remote ←" } else { "Local →" }).show_ui(ui, |ui| {
ui.selectable_value(&mut f.remote, false, "Local →");
ui.selectable_value(&mut f.remote, true, "Remote ←");
});
ui.add(egui::TextEdit::singleline(&mut f.bind_host).hint_text("127.0.0.1").desired_width(110.0));
ui.add(egui::TextEdit::singleline(&mut f.bind_port).hint_text("Port").desired_width(52.0));
ui.label(RichText::new(icon::ARROW_RIGHT).color(t.text_faint));
ui.add(egui::TextEdit::singleline(&mut f.dest_host).hint_text("localhost").desired_width(110.0));
ui.add(egui::TextEdit::singleline(&mut f.dest_port).hint_text("Port").desired_width(52.0));
});
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
start = widgets::primary(ui, icon::PLUS, "Start Forward").clicked();
ui.add(egui::TextEdit::singleline(&mut f.label).hint_text("Label (optional)").desired_width(f32::INFINITY));
});
});
});
if let Some(id) = stop {
let (svc, sid, done) = (s.svc.clone(), sid.clone(), self.done.clone());
s.spawn(move || svc.sessions.stop_forward(&sid, &id).map_err(|e| format!("Failed to stop: {e}").into()), move |_| done.lock().push(false));
}
if start {
let f = &self.form;
match (port(&f.bind_port), port(&f.dest_port)) {
(Some(bind_port), Some(dest_port)) => {
let or = |s: &str, d: &str| {
if s.trim().is_empty() { d.to_string() } else { s.trim().to_string() }
};
let rule = PortForward {
id: new_id(),
direction: if f.remote { ForwardDirection::Remote } else { ForwardDirection::Local },
bind_host: or(&f.bind_host, "127.0.0.1"),
bind_port,
dest_host: or(&f.dest_host, "localhost"),
dest_port,
enabled: true,
label: f.label.trim().into(),
};
let (svc, done) = (s.svc.clone(), self.done.clone());
s.spawn(
move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()),
move |_| done.lock().push(true),
);
}
_ => s.error("Bind port and destination port are required"),
}
}
if !keep {
self.session = None;
}
}
}