rust: merge UI feature modules; fix Ctrl+key on Linux/Windows (egui command_only shadowing); clippy clean; rustfmt config
This commit is contained in:
@@ -25,4 +25,5 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev libgtk-3-dev openssh-server
|
||||
- run: cargo test --locked --workspace
|
||||
- run: cargo clippy --locked --workspace -- -D warnings
|
||||
- run: cargo fmt --all --check
|
||||
- run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||
|
||||
+72
-43
@@ -41,17 +41,43 @@ pub enum Prompt {
|
||||
/// Modal dialogs owned by the main window.
|
||||
pub enum Dialog {
|
||||
/// A profile already has a live session.
|
||||
Reuse { profile: String, existing: String },
|
||||
Reuse {
|
||||
profile: String,
|
||||
existing: String,
|
||||
},
|
||||
/// Unencrypted VNC without an SSH tunnel.
|
||||
DirectVnc { profile: String },
|
||||
ConfirmClose { session: String },
|
||||
ConfirmDisconnectMany { sessions: Vec<String> },
|
||||
Rename { session: String, name: String },
|
||||
Broadcast { picks: Vec<(String, String, bool)> },
|
||||
DirectVnc {
|
||||
profile: String,
|
||||
},
|
||||
ConfirmClose {
|
||||
session: String,
|
||||
},
|
||||
ConfirmDisconnectMany {
|
||||
sessions: Vec<String>,
|
||||
},
|
||||
Rename {
|
||||
session: String,
|
||||
name: String,
|
||||
},
|
||||
Broadcast {
|
||||
picks: Vec<(String, String, bool)>,
|
||||
},
|
||||
/// Guided help for an auth or host-key failure.
|
||||
Guided { session: String, title: String, body: String, known_hosts: bool },
|
||||
Delete { profiles: Vec<String> },
|
||||
Input { title: String, label: String, value: String, then: InputThen },
|
||||
Guided {
|
||||
session: String,
|
||||
title: String,
|
||||
body: String,
|
||||
known_hosts: bool,
|
||||
},
|
||||
Delete {
|
||||
profiles: Vec<String>,
|
||||
},
|
||||
Input {
|
||||
title: String,
|
||||
label: String,
|
||||
value: String,
|
||||
then: InputThen,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum InputThen {
|
||||
@@ -205,7 +231,9 @@ impl App {
|
||||
|
||||
/// Registers a just-opened session as a pane (and a tab unless it's a split child).
|
||||
pub fn add_session(&mut self, id: &str, tab: bool) {
|
||||
let Some(info) = self.s.svc.sessions.info(id) else { return };
|
||||
let Some(info) = self.s.svc.sessions.info(id) else {
|
||||
return;
|
||||
};
|
||||
let profile = self.s.svc.sessions.profile(id);
|
||||
let view = self.view_for(&info, profile.as_ref());
|
||||
self.s.panes.insert(id.into(), Pane { info, view, recording: false, local_exited: false, connected_since: None, highlight_until: 0.0 });
|
||||
@@ -224,7 +252,8 @@ impl App {
|
||||
};
|
||||
if !skip_checks {
|
||||
let live = self.s.panes.values().find(|pn| {
|
||||
pn.info.profile_id == id && matches!(self.s.session_state(&pn.info.id), Some(SessionState::Connected | SessionState::Connecting | SessionState::Reconnecting))
|
||||
pn.info.profile_id == id
|
||||
&& matches!(self.s.session_state(&pn.info.id), Some(SessionState::Connected | SessionState::Connecting | SessionState::Reconnecting))
|
||||
});
|
||||
if let Some(pn) = live {
|
||||
self.dialog = Some(Dialog::Reuse { profile: id.into(), existing: pn.info.id.clone() });
|
||||
@@ -365,10 +394,10 @@ impl App {
|
||||
|
||||
fn mark_activity(&mut self, session: &str) {
|
||||
let active = self.s.active.clone();
|
||||
if let Some(t) = self.s.tabs.iter_mut().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session))) {
|
||||
if active.as_deref() != Some(&t.id) {
|
||||
t.activity = true;
|
||||
}
|
||||
if let Some(t) = self.s.tabs.iter_mut().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session)))
|
||||
&& active.as_deref() != Some(&t.id)
|
||||
{
|
||||
t.activity = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +496,9 @@ impl App {
|
||||
p.info.error = error.into();
|
||||
}
|
||||
}
|
||||
let Some(p) = self.s.pane_mut(session) else { return };
|
||||
let Some(p) = self.s.pane_mut(session) else {
|
||||
return;
|
||||
};
|
||||
let local = p.info.protocol == Protocol::Local;
|
||||
match state {
|
||||
SessionState::Connected => {
|
||||
@@ -477,7 +508,9 @@ impl App {
|
||||
let _ = self.s.svc.sessions.write(session, format!("{cmd}\n").as_bytes());
|
||||
self.run_local = None;
|
||||
}
|
||||
let Some(p) = self.s.pane_mut(session) else { return };
|
||||
let Some(p) = self.s.pane_mut(session) else {
|
||||
return;
|
||||
};
|
||||
p.local_exited = false;
|
||||
p.connected_since = Some(chrono::Local::now());
|
||||
// The PTY was created before the view knew its size: send it now.
|
||||
@@ -520,7 +553,6 @@ impl App {
|
||||
self.add_session(&sid, true);
|
||||
self.s.info(format!("Connecting to {name}…"));
|
||||
}
|
||||
Request::ConnectLocal => self.connect_local(),
|
||||
Request::ReloadProfiles => self.s.reload_profiles(),
|
||||
Request::ReloadPreferences => {
|
||||
self.s.prefs = self.s.svc.preferences();
|
||||
@@ -528,26 +560,14 @@ impl App {
|
||||
self.theme = theme_for(&self.s.prefs.theme);
|
||||
theme::apply(&self.s.ctx, self.theme);
|
||||
}
|
||||
Request::QuickConnect => self.quick.open(),
|
||||
Request::Settings(t) => self.settings.open(t),
|
||||
Request::SshKeys => self.keys.open(),
|
||||
Request::ExportEncrypted(ids) => self.transfer.export_encrypted(ids),
|
||||
Request::ImportEncrypted => self.transfer.import_encrypted(),
|
||||
Request::ExportJson => self.transfer.export_json(),
|
||||
Request::ImportJson => self.transfer.import_json(),
|
||||
Request::Recordings => self.recordings.open(),
|
||||
Request::Snippets => self.snippets.open(),
|
||||
Request::Triggers => self.triggers.open(),
|
||||
Request::Sftp(id) => self.sftp.toggle(&id),
|
||||
Request::PortForwards(id) => self.forwards.open(&id),
|
||||
Request::SendToActive(text) => match self.s.focused_session() {
|
||||
Some(id) if self.s.session_state(&id) == Some(SessionState::Connected) => self.send_input(&id, text.as_bytes()),
|
||||
_ => self.s.error("No active connected session"),
|
||||
},
|
||||
Request::Write(id, data) => self.send_input(&id, &data),
|
||||
Request::Disconnect(id) => self.close_session(&id),
|
||||
Request::CommandPalette => self.palette = Some(chrome::Palette::default()),
|
||||
Request::About => self.settings.open(SettingsTab::About),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -754,7 +774,9 @@ impl App {
|
||||
// --- screenshot automation ---------------------------------------------------------
|
||||
|
||||
fn screenshot(&mut self, ctx: &egui::Context) {
|
||||
let Some((path, frames)) = &mut self.screenshot else { return };
|
||||
let Some((path, frames)) = &mut self.screenshot else {
|
||||
return;
|
||||
};
|
||||
*frames += 1;
|
||||
if *frames < 40 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(15));
|
||||
@@ -824,26 +846,33 @@ impl eframe::App for App {
|
||||
if std::mem::take(&mut self.resize_sidebar) {
|
||||
panel = panel.exact_size(width);
|
||||
}
|
||||
let side = panel
|
||||
.min_size(180.0)
|
||||
.max_size(500.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.dock))
|
||||
.show(ui, |ui| sidebar::ui(self, ui));
|
||||
let side = panel.min_size(180.0).max_size(500.0).resizable(true).frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| sidebar::ui(self, ui));
|
||||
let w = side.response.rect.width().round() as i64;
|
||||
if (w - self.s.prefs.sidebar_width).abs() > 2 && !ctx.input(|i| i.pointer.any_down()) {
|
||||
self.s.prefs.sidebar_width = w;
|
||||
let _ = self.s.svc.save_preferences(&self.s.prefs);
|
||||
}
|
||||
if self.editor.is_open() {
|
||||
egui::Panel::right("editor").default_size(460.0).min_size(380.0).max_size(800.0).resizable(true).frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)).show(ui, |ui| {
|
||||
self.editor.ui(ui, &mut self.s);
|
||||
});
|
||||
egui::Panel::right("editor")
|
||||
.default_size(460.0)
|
||||
.min_size(380.0)
|
||||
.max_size(800.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.dock).inner_margin(6))
|
||||
.show(ui, |ui| {
|
||||
self.editor.ui(ui, &mut self.s);
|
||||
});
|
||||
} else if let Some(id) = self.sftp.open_for().map(String::from) {
|
||||
if self.s.pane(&id).is_some() {
|
||||
egui::Panel::right("sftp").default_size(380.0).min_size(280.0).max_size(800.0).resizable(true).frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)).show(ui, |ui| {
|
||||
self.sftp.ui(ui, &mut self.s);
|
||||
});
|
||||
egui::Panel::right("sftp")
|
||||
.default_size(380.0)
|
||||
.min_size(280.0)
|
||||
.max_size(800.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.dock).inner_margin(6))
|
||||
.show(ui, |ui| {
|
||||
self.sftp.ui(ui, &mut self.s);
|
||||
});
|
||||
} else {
|
||||
self.sftp.close(&id);
|
||||
}
|
||||
|
||||
+66
-16
@@ -194,7 +194,13 @@ pub fn title_bar(app: &mut App, ui: &mut Ui) {
|
||||
let resp = ui.interact(r, ui.id().with("palette-launch"), Sense::click());
|
||||
ui.painter().rect(r, 6.0, if resp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
ui.painter().text(r.left_center() + vec2(9.0, 0.0), Align2::LEFT_CENTER, icon::MAGNIFYING_GLASS, egui::FontId::proportional(13.0), t.text_faint);
|
||||
ui.painter().with_clip_rect(r.shrink(2.0)).text(r.left_center() + vec2(28.0, 0.0), Align2::LEFT_CENTER, "Search connections and commands", egui::FontId::proportional(12.0), t.text_faint);
|
||||
ui.painter().with_clip_rect(r.shrink(2.0)).text(
|
||||
r.left_center() + vec2(28.0, 0.0),
|
||||
Align2::LEFT_CENTER,
|
||||
"Search connections and commands",
|
||||
egui::FontId::proportional(12.0),
|
||||
t.text_faint,
|
||||
);
|
||||
if w > 250.0 {
|
||||
ui.painter().text(r.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, keys("Mod+K").replace('+', " "), theme::semibold(10.5), t.text_faint);
|
||||
}
|
||||
@@ -257,7 +263,9 @@ pub fn resize_edges(ctx: &egui::Context) {
|
||||
(Rect::from_min_max(r.min, pos2(r.left() + e, r.bottom())), D::West, CursorIcon::ResizeHorizontal),
|
||||
(Rect::from_min_max(pos2(r.right() - e, r.top()), r.max), D::East, CursorIcon::ResizeHorizontal),
|
||||
];
|
||||
let Some(p) = ctx.input(|i| i.pointer.hover_pos()) else { return };
|
||||
let Some(p) = ctx.input(|i| i.pointer.hover_pos()) else {
|
||||
return;
|
||||
};
|
||||
for (zone, dir, cursor) in zones {
|
||||
if zone.contains(p) {
|
||||
ctx.set_cursor_icon(cursor);
|
||||
@@ -317,7 +325,13 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) {
|
||||
&& active.as_deref() == Some(sid)
|
||||
&& let Ok(ms) = r
|
||||
{
|
||||
let c = if *ms < 100 { t.success } else if *ms < 250 { t.warning } else { t.danger };
|
||||
let c = if *ms < 100 {
|
||||
t.success
|
||||
} else if *ms < 250 {
|
||||
t.warning
|
||||
} else {
|
||||
t.danger
|
||||
};
|
||||
ui.label(RichText::new(format!("{ms} ms")).size(11.5).color(c));
|
||||
}
|
||||
if app.s.broadcast {
|
||||
@@ -328,7 +342,15 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) {
|
||||
let vault = app.s.svc.secrets.vault.is_unlocked();
|
||||
let keychain = app.s.svc.secrets.keychain_ok;
|
||||
let label = if vault { format!("{} Vault unlocked", icon::LOCK_OPEN) } else { format!("{} Vault locked", icon::LOCK) };
|
||||
if ui.add(egui::Label::new(dim(label)).sense(Sense::click())).on_hover_text(if keychain { "Saved credentials use the OS keychain; the vault is the fallback." } else { "No OS keychain available: saved credentials use the encrypted vault." }).clicked() {
|
||||
if ui
|
||||
.add(egui::Label::new(dim(label)).sense(Sense::click()))
|
||||
.on_hover_text(if keychain {
|
||||
"Saved credentials use the OS keychain; the vault is the fallback."
|
||||
} else {
|
||||
"No OS keychain available: saved credentials use the encrypted vault."
|
||||
})
|
||||
.clicked()
|
||||
{
|
||||
app.settings.open(SettingsTab::Security);
|
||||
}
|
||||
let total = app.s.panes.len();
|
||||
@@ -337,7 +359,11 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) {
|
||||
if let Some(u) = &app.update
|
||||
&& u.available
|
||||
{
|
||||
let txt = format!("{} Update available{}", icon::ARROW_CIRCLE_UP, if u.latest_version.is_empty() { String::new() } else { format!(" ({})", u.latest_version) });
|
||||
let txt = format!(
|
||||
"{} Update available{}",
|
||||
icon::ARROW_CIRCLE_UP,
|
||||
if u.latest_version.is_empty() { String::new() } else { format!(" ({})", u.latest_version) }
|
||||
);
|
||||
if ui.add(egui::Label::new(RichText::new(txt).size(11.5).color(t.accent)).sense(Sense::click())).clicked() {
|
||||
app.settings.open(SettingsTab::About);
|
||||
}
|
||||
@@ -397,6 +423,8 @@ enum Cmd {
|
||||
Settings,
|
||||
Import,
|
||||
Export,
|
||||
ExportJson,
|
||||
ImportJson,
|
||||
Lock,
|
||||
Unlock,
|
||||
Keys,
|
||||
@@ -435,7 +463,12 @@ fn commands(app: &App) -> Vec<(Cmd, String, &'static str, &'static str, String)>
|
||||
let name = if p.display_name.is_empty() { &p.host } else { &p.display_name };
|
||||
v.push((Cmd::Connect(p.id.clone()), format!("Connect to {name}"), "Connections", icon::PLUGS_CONNECTED, String::new()));
|
||||
}
|
||||
for (pr, label) in [(Protocol::Ssh, "New SSH Connection"), (Protocol::Telnet, "New Telnet Connection"), (Protocol::Raw, "New RAW Connection"), (Protocol::Serial, "New Serial Connection")] {
|
||||
for (pr, label) in [
|
||||
(Protocol::Ssh, "New SSH Connection"),
|
||||
(Protocol::Telnet, "New Telnet Connection"),
|
||||
(Protocol::Raw, "New RAW Connection"),
|
||||
(Protocol::Serial, "New Serial Connection"),
|
||||
] {
|
||||
v.push((Cmd::NewProfile(pr), label.into(), "Actions", icon::PLUS_CIRCLE, if pr == Protocol::Ssh { keys("Mod+N") } else { String::new() }));
|
||||
}
|
||||
v.push((Cmd::Quick, "Quick Connect…".into(), "Actions", icon::LIGHTNING, keys("Mod+Shift+Q")));
|
||||
@@ -456,6 +489,8 @@ fn commands(app: &App) -> Vec<(Cmd, String, &'static str, &'static str, String)>
|
||||
v.push((Cmd::Settings, "Settings".into(), "Application", icon::GEAR, keys("Mod+,")));
|
||||
v.push((Cmd::Import, "Import Profiles".into(), "Application", icon::DOWNLOAD_SIMPLE, String::new()));
|
||||
v.push((Cmd::Export, "Export Profiles".into(), "Application", icon::UPLOAD_SIMPLE, String::new()));
|
||||
v.push((Cmd::ImportJson, "Import Profiles (JSON)".into(), "Application", icon::FILE_ARROW_DOWN, String::new()));
|
||||
v.push((Cmd::ExportJson, "Export Profiles (JSON)".into(), "Application", icon::FILE_ARROW_UP, String::new()));
|
||||
for k in ThemeKind::ALL {
|
||||
v.push((Cmd::Theme(k), format!("Theme: {}", k.label()), "Application", icon::PALETTE, String::new()));
|
||||
}
|
||||
@@ -501,6 +536,8 @@ fn run(app: &mut App, ctx: &egui::Context, c: Cmd) {
|
||||
Cmd::Settings => app.settings.open(SettingsTab::General),
|
||||
Cmd::Import => app.s.request(Request::ImportEncrypted),
|
||||
Cmd::Export => app.s.request(Request::ExportEncrypted(vec![])),
|
||||
Cmd::ExportJson => app.transfer.export_json(),
|
||||
Cmd::ImportJson => app.transfer.import_json(),
|
||||
Cmd::Lock => {
|
||||
app.s.svc.secrets.vault.lock();
|
||||
app.s.info("Secret store locked");
|
||||
@@ -536,14 +573,13 @@ pub fn modal_backdrop(ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
pub fn palette(app: &mut App, ctx: &egui::Context) {
|
||||
let Some(mut pal) = app.palette.take() else { return };
|
||||
let Some(mut pal) = app.palette.take() else {
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let mut matches: Vec<_> = commands(app)
|
||||
.into_iter()
|
||||
.filter_map(|c| fuzzy_score(&format!("{} {}", c.1, c.2), pal.query.trim()).map(|s| (s, c)))
|
||||
.collect();
|
||||
let mut matches: Vec<_> = commands(app).into_iter().filter_map(|c| fuzzy_score(&format!("{} {}", c.1, c.2), pal.query.trim()).map(|s| (s, c))).collect();
|
||||
if !pal.query.trim().is_empty() {
|
||||
matches.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
matches.sort_by_key(|m| std::cmp::Reverse(m.0));
|
||||
}
|
||||
let matches: Vec<_> = matches.into_iter().map(|(_, c)| c).collect();
|
||||
pal.sel = pal.sel.min(matches.len().saturating_sub(1));
|
||||
@@ -564,7 +600,13 @@ pub fn palette(app: &mut App, ctx: &egui::Context) {
|
||||
ui.set_width(580.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(icon::MAGNIFYING_GLASS).size(16.0).color(t.text_faint));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut pal.query).hint_text("Connect, switch tab, or run a command…").frame(egui::Frame::NONE).desired_width(f32::INFINITY).font(egui::FontId::proportional(15.0)));
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut pal.query)
|
||||
.hint_text("Connect, switch tab, or run a command…")
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY)
|
||||
.font(egui::FontId::proportional(15.0)),
|
||||
);
|
||||
r.request_focus();
|
||||
if r.changed() {
|
||||
pal.sel = 0;
|
||||
@@ -622,7 +664,9 @@ pub fn modal(ctx: &egui::Context, id: &str, width: f32, body: impl FnOnce(&mut U
|
||||
}
|
||||
|
||||
pub fn prompts(app: &mut App, ctx: &egui::Context) {
|
||||
let Some(p) = app.prompts.front_mut() else { return };
|
||||
let Some(p) = app.prompts.front_mut() else {
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let esc = ctx.input(|i| i.key_pressed(Key::Escape));
|
||||
let enter = ctx.input(|i| i.key_pressed(Key::Enter));
|
||||
@@ -680,7 +724,9 @@ pub fn prompts(app: &mut App, ctx: &egui::Context) {
|
||||
// --- dialogs --------------------------------------------------------------------------------
|
||||
|
||||
pub fn dialogs(app: &mut App, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
let Some(mut d) = app.dialog.take() else {
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let esc = ctx.input(|i| i.key_pressed(Key::Escape));
|
||||
let mut keep = !esc;
|
||||
@@ -853,7 +899,11 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) {
|
||||
let names: Vec<String> = profiles.iter().filter_map(|id| app.s.profile(id)).map(|p| p.display_name.clone()).collect();
|
||||
modal(ctx, "delete", 380.0, |ui| {
|
||||
ui.label(RichText::new("Delete connection?").font(theme::semibold(15.0)));
|
||||
ui.label(if names.len() == 1 { format!("\"{}\" will be deleted, with its saved credentials.", names[0]) } else { format!("{} connections will be deleted.", names.len()) });
|
||||
ui.label(if names.len() == 1 {
|
||||
format!("\"{}\" will be deleted, with its saved credentials.", names[0])
|
||||
} else {
|
||||
format!("{} connections will be deleted.", names.len())
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add(egui::Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(Color32::WHITE)).fill(t.danger)).clicked() {
|
||||
|
||||
@@ -20,7 +20,14 @@ struct Form {
|
||||
|
||||
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() }
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +59,9 @@ impl Forwards {
|
||||
s.info("Forward stopped");
|
||||
}
|
||||
}
|
||||
let Some(sid) = self.session.clone() else { return };
|
||||
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,
|
||||
@@ -84,7 +93,9 @@ impl Forwards {
|
||||
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 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() {
|
||||
@@ -126,7 +137,9 @@ impl Forwards {
|
||||
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 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 },
|
||||
@@ -138,7 +151,10 @@ impl Forwards {
|
||||
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().unwrap().push(true));
|
||||
s.spawn(
|
||||
move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()),
|
||||
move |_| done.lock().unwrap().push(true),
|
||||
);
|
||||
}
|
||||
_ => s.error("Bind port and destination port are required"),
|
||||
}
|
||||
|
||||
+60
-44
@@ -61,12 +61,16 @@ fn grouped<'a>(repos: &'a [GitHubRepo], login: &str, filter: &str) -> Vec<(Strin
|
||||
|
||||
/// "just now", "3 minutes ago", "1 day ago"… (the web build's relativeTime).
|
||||
fn relative_time(iso: &str, now: DateTime<Utc>) -> String {
|
||||
let Some(d) = parse_time(iso) else { return String::new() };
|
||||
let Some(d) = parse_time(iso) else {
|
||||
return String::new();
|
||||
};
|
||||
let secs = (now - d).num_seconds();
|
||||
if secs < 60 {
|
||||
return "just now".into();
|
||||
}
|
||||
let plural = |n: i64, unit: &str| if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") };
|
||||
let plural = |n: i64, unit: &str| {
|
||||
if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") }
|
||||
};
|
||||
let mins = secs / 60;
|
||||
let hrs = mins / 60;
|
||||
let days = hrs / 24;
|
||||
@@ -120,6 +124,7 @@ fn lang_colour(lang: &str) -> Color32 {
|
||||
Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)] // short-lived, one per click
|
||||
enum Action {
|
||||
Zip(GitHubRepo),
|
||||
Visit(String),
|
||||
@@ -178,7 +183,9 @@ impl GithubView {
|
||||
fn download(&mut self, s: &Shared, repo: &GitHubRepo) {
|
||||
let reference = repo.default_branch.clone();
|
||||
let name = if reference.is_empty() { format!("{}.zip", repo.name) } else { format!("{}-{reference}.zip", repo.name) };
|
||||
let Some(path) = rfd::FileDialog::new().set_file_name(name).add_filter("ZIP archive", &["zip"]).save_file() else { return };
|
||||
let Some(path) = rfd::FileDialog::new().set_file_name(name).add_filter("ZIP archive", &["zip"]).save_file() else {
|
||||
return;
|
||||
};
|
||||
self.on_status(format!("Downloading {}...", repo.full_name));
|
||||
let (svc, sid, inbox, ctx, full) = (s.svc.clone(), self.session.clone(), self.inbox.clone(), s.ctx.clone(), repo.full_name.clone());
|
||||
std::thread::spawn(move || {
|
||||
@@ -221,7 +228,11 @@ impl GithubView {
|
||||
if widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh now").clicked() {
|
||||
self.refresh(s);
|
||||
}
|
||||
ui.add(egui::TextEdit::singleline(&mut self.filter).hint_text(format!("{} Filter repositories…", icon::MAGNIFYING_GLASS)).desired_width(240.0));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.filter)
|
||||
.hint_text(format!("{} Filter repositories…", icon::MAGNIFYING_GLASS))
|
||||
.desired_width(240.0),
|
||||
);
|
||||
});
|
||||
});
|
||||
let c = if self.status_error { t.danger } else { t.text_dim };
|
||||
@@ -270,50 +281,55 @@ impl GithubView {
|
||||
fn card(ui: &mut Ui, r: &GitHubRepo, now: DateTime<Utc>) -> Option<Action> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut action = None;
|
||||
let resp = egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.card_border)).corner_radius(6).inner_margin(egui::Margin::symmetric(12, 9)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&r.name).font(theme::semibold(13.5)).color(t.accent));
|
||||
if r.private {
|
||||
widgets::chip(ui, "Private", t.warning);
|
||||
}
|
||||
if r.fork {
|
||||
widgets::chip(ui, "Fork", t.text_dim);
|
||||
}
|
||||
if r.archived {
|
||||
widgets::chip(ui, "Archived", t.danger);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(format!("{} Visit", icon::ARROW_SQUARE_OUT)).on_hover_text("Open repository in browser").clicked() {
|
||||
action = Some(Action::Visit(r.html_url.clone()));
|
||||
let resp = egui::Frame::new()
|
||||
.fill(t.card)
|
||||
.stroke(egui::Stroke::new(1.0, t.card_border))
|
||||
.corner_radius(6)
|
||||
.inner_margin(egui::Margin::symmetric(12, 9))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&r.name).font(theme::semibold(13.5)).color(t.accent));
|
||||
if r.private {
|
||||
widgets::chip(ui, "Private", t.warning);
|
||||
}
|
||||
if ui.button(format!("{} ZIP", icon::FILE_ZIP)).on_hover_text("Download repository as zip").clicked() {
|
||||
action = Some(Action::Zip(r.clone()));
|
||||
if r.fork {
|
||||
widgets::chip(ui, "Fork", t.text_dim);
|
||||
}
|
||||
if r.archived {
|
||||
widgets::chip(ui, "Archived", t.danger);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button(format!("{} Visit", icon::ARROW_SQUARE_OUT)).on_hover_text("Open repository in browser").clicked() {
|
||||
action = Some(Action::Visit(r.html_url.clone()));
|
||||
}
|
||||
if ui.button(format!("{} ZIP", icon::FILE_ZIP)).on_hover_text("Download repository as zip").clicked() {
|
||||
action = Some(Action::Zip(r.clone()));
|
||||
}
|
||||
});
|
||||
});
|
||||
if !r.latest_committer.is_empty() && !r.latest_commit_at.is_empty() {
|
||||
ui.label(RichText::new(format!("Last commit: {} by {}", local_time(&r.latest_commit_at), r.latest_committer)).color(t.text_faint).size(11.5));
|
||||
}
|
||||
if !r.description.is_empty() {
|
||||
ui.label(RichText::new(&r.description).color(t.text_dim));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if !r.language.is_empty() {
|
||||
let (dot, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
|
||||
ui.painter().circle_filled(dot.center(), 5.0, lang_colour(&r.language));
|
||||
ui.label(RichText::new(&r.language).color(t.text_dim).size(11.5));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if r.stars > 0 {
|
||||
ui.label(RichText::new(format!("{} {}", icon::STAR, widgets::group(r.stars as u64))).color(t.text_dim).size(11.5));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if !r.pushed_at.is_empty() {
|
||||
ui.label(RichText::new(relative_time(&r.pushed_at, now)).color(t.text_faint).size(11.5)).on_hover_text(local_time(&r.pushed_at));
|
||||
}
|
||||
});
|
||||
});
|
||||
if !r.latest_committer.is_empty() && !r.latest_commit_at.is_empty() {
|
||||
ui.label(RichText::new(format!("Last commit: {} by {}", local_time(&r.latest_commit_at), r.latest_committer)).color(t.text_faint).size(11.5));
|
||||
}
|
||||
if !r.description.is_empty() {
|
||||
ui.label(RichText::new(&r.description).color(t.text_dim));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if !r.language.is_empty() {
|
||||
let (dot, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
|
||||
ui.painter().circle_filled(dot.center(), 5.0, lang_colour(&r.language));
|
||||
ui.label(RichText::new(&r.language).color(t.text_dim).size(11.5));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if r.stars > 0 {
|
||||
ui.label(RichText::new(format!("{} {}", icon::STAR, widgets::group(r.stars as u64))).color(t.text_dim).size(11.5));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if !r.pushed_at.is_empty() {
|
||||
ui.label(RichText::new(relative_time(&r.pushed_at, now)).color(t.text_faint).size(11.5)).on_hover_text(local_time(&r.pushed_at));
|
||||
}
|
||||
});
|
||||
});
|
||||
let resp = resp.response.interact(Sense::click());
|
||||
if resp.double_clicked() {
|
||||
action = Some(Action::Visit(r.html_url.clone()));
|
||||
|
||||
@@ -317,7 +317,9 @@ impl IrcView {
|
||||
fn log(&mut self, ui: &mut Ui, theme: &'static TermTheme) {
|
||||
let font = FontId::monospace(self.font_size);
|
||||
let wrap = ui.available_width().max(100.0);
|
||||
let Some(target) = self.targets.iter_mut().find(|t| t.name == self.active) else { return };
|
||||
let Some(target) = self.targets.iter_mut().find(|t| t.name == self.active) else {
|
||||
return;
|
||||
};
|
||||
// Lay out lines once per width/theme; the galley cache keeps scrolling cheap.
|
||||
let mut tops = Vec::with_capacity(target.lines.len());
|
||||
let mut y = 0.0;
|
||||
@@ -453,7 +455,8 @@ impl IrcView {
|
||||
ui.label(RichText::new(r.users.to_string()).color(t.text_faint).size(11.0));
|
||||
ui.label(RichText::new(ircformat::plain_text(&r.topic)).color(t.text_faint).size(11.0));
|
||||
});
|
||||
ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(format!("{}\nClick to join", ircformat::plain_text(if r.topic.is_empty() { &r.name } else { &r.topic })));
|
||||
ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover())
|
||||
.on_hover_text(format!("{}\nClick to join", ircformat::plain_text(if r.topic.is_empty() { &r.name } else { &r.topic })));
|
||||
});
|
||||
join_room = clicked.map(|i| listed[i].name.clone());
|
||||
});
|
||||
@@ -474,7 +477,7 @@ impl IrcView {
|
||||
ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(format!("{}\nClick to mention", u[i]));
|
||||
});
|
||||
if let Some(i) = clicked {
|
||||
self.insert_mention(&display_nick(&u[i]).to_string());
|
||||
self.insert_mention(display_nick(&u[i]));
|
||||
}
|
||||
}
|
||||
_ => widgets::hint(ui, "No users yet"),
|
||||
|
||||
@@ -208,7 +208,10 @@ impl ProfileEditor {
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
let errs = if self.show_errors { self.validate() } else { vec![] };
|
||||
egui::Panel::bottom("profile-editor-footer").resizable(false).frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::symmetric(0, 6))).show(ui, |ui| self.footer(ui, s, &errs));
|
||||
egui::Panel::bottom("profile-editor-footer")
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::symmetric(0, 6)))
|
||||
.show(ui, |ui| self.footer(ui, s, &errs));
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.add_space(4.0);
|
||||
self.general(ui, &errs);
|
||||
@@ -240,7 +243,12 @@ impl ProfileEditor {
|
||||
if !errs.is_empty() {
|
||||
let all = errs.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("\n");
|
||||
let n = errs.len();
|
||||
ui.label(RichText::new(format!("{} {n} problem{} — see the highlighted fields", icon::WARNING_CIRCLE, if n == 1 { "" } else { "s" })).color(t.danger).size(12.0)).on_hover_text(all);
|
||||
ui.label(
|
||||
RichText::new(format!("{} {n} problem{} — see the highlighted fields", icon::WARNING_CIRCLE, if n == 1 { "" } else { "s" }))
|
||||
.color(t.danger)
|
||||
.size(12.0),
|
||||
)
|
||||
.on_hover_text(all);
|
||||
}
|
||||
if self.confirm_delete {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -435,11 +443,14 @@ impl ProfileEditor {
|
||||
item(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let opts = [(ForwardDirection::Local, "Local →"), (ForwardDirection::Remote, "← Remote")];
|
||||
ComboBox::from_id_salt(("pf-dir", i)).width(84.0).selected_text(if pf.direction == ForwardDirection::Local { opts[0].1 } else { opts[1].1 }).show_ui(ui, |ui| {
|
||||
for (v, l) in opts {
|
||||
ui.selectable_value(&mut pf.direction, v, l);
|
||||
}
|
||||
});
|
||||
ComboBox::from_id_salt(("pf-dir", i))
|
||||
.width(84.0)
|
||||
.selected_text(if pf.direction == ForwardDirection::Local { opts[0].1 } else { opts[1].1 })
|
||||
.show_ui(ui, |ui| {
|
||||
for (v, l) in opts {
|
||||
ui.selectable_value(&mut pf.direction, v, l);
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
act = row_buttons(ui, i, n).or(act.take());
|
||||
ui.checkbox(&mut pf.enabled, "Active").on_hover_text("Start on connect");
|
||||
@@ -460,13 +471,22 @@ impl ProfileEditor {
|
||||
apply(&mut c.port_forwards, act);
|
||||
list_errs(ui, errs, "ssh.portForwards");
|
||||
if ui.button(format!("{} Add Port Forward", icon::PLUS)).clicked() {
|
||||
c.port_forwards.push(PortForward { id: new_id(), bind_host: "127.0.0.1".into(), bind_port: 1, dest_port: 1, enabled: true, ..Default::default() });
|
||||
c.port_forwards.push(PortForward {
|
||||
id: new_id(),
|
||||
bind_host: "127.0.0.1".into(),
|
||||
bind_port: 1,
|
||||
dest_port: 1,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
});
|
||||
section(ui, "Startup", |ui| {
|
||||
text(ui, "Startup Command", &mut c.startup_command, "Optional command to run after connecting");
|
||||
label(ui, "Environment Variables");
|
||||
ui.add(TextEdit::multiline(env).hint_text("KEY=VALUE (one per line)").desired_rows(3).desired_width(f32::INFINITY).font(egui::TextStyle::Monospace));
|
||||
ui.add(
|
||||
TextEdit::multiline(env).hint_text("KEY=VALUE (one per line)").desired_rows(3).desired_width(f32::INFINITY).font(egui::TextStyle::Monospace),
|
||||
);
|
||||
});
|
||||
section(ui, "Login Script", |ui| login_script(ui, "ssh-login", &mut c.login_scripts));
|
||||
section(ui, "SSH Agent", |ui| {
|
||||
@@ -488,7 +508,9 @@ impl ProfileEditor {
|
||||
if let Some((_, until)) = totp_flash {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((*until - s.now).max(0.0)));
|
||||
}
|
||||
let test = ui.add(Button::new(RichText::new(flash.as_deref().unwrap_or("Test")).font(theme::mono(12.5)))).on_hover_text("Generate a code with the saved secret");
|
||||
let test = ui
|
||||
.add(Button::new(RichText::new(flash.as_deref().unwrap_or("Test")).font(theme::mono(12.5))))
|
||||
.on_hover_text("Generate a code with the saved secret");
|
||||
if test.clicked() {
|
||||
*totp_flash = Some(match (*is_new, s.svc.totp_code(&id)) {
|
||||
(false, Ok(code)) => (code, s.now + 5.0),
|
||||
@@ -634,7 +656,24 @@ impl ProfileEditor {
|
||||
err(ui, errs, "vnc.quality");
|
||||
});
|
||||
section(ui, "SSH Tunnel", |ui| {
|
||||
tunnel(ui, "vnc", Tunnel { on: &mut c.ssh_tunnel, host: &mut c.ssh_host, port: &mut c.ssh_port, user: &mut c.ssh_username, auth: &mut c.ssh_auth_method, key: &mut c.ssh_private_key, agent: &mut c.ssh_agent_auth, mode: &mut c.ssh_secret_mode }, keys, key_checks, secrets, errs);
|
||||
tunnel(
|
||||
ui,
|
||||
"vnc",
|
||||
Tunnel {
|
||||
on: &mut c.ssh_tunnel,
|
||||
host: &mut c.ssh_host,
|
||||
port: &mut c.ssh_port,
|
||||
user: &mut c.ssh_username,
|
||||
auth: &mut c.ssh_auth_method,
|
||||
key: &mut c.ssh_private_key,
|
||||
agent: &mut c.ssh_agent_auth,
|
||||
mode: &mut c.ssh_secret_mode,
|
||||
},
|
||||
keys,
|
||||
key_checks,
|
||||
secrets,
|
||||
errs,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -686,7 +725,24 @@ impl ProfileEditor {
|
||||
}
|
||||
});
|
||||
section(ui, "SSH Tunnel", |ui| {
|
||||
tunnel(ui, "irc", Tunnel { on: &mut c.ssh_tunnel, host: &mut c.ssh_host, port: &mut c.ssh_port, user: &mut c.ssh_username, auth: &mut c.ssh_auth_method, key: &mut c.ssh_private_key, agent: &mut c.ssh_agent_auth, mode: &mut c.ssh_secret_mode }, keys, key_checks, secrets, errs);
|
||||
tunnel(
|
||||
ui,
|
||||
"irc",
|
||||
Tunnel {
|
||||
on: &mut c.ssh_tunnel,
|
||||
host: &mut c.ssh_host,
|
||||
port: &mut c.ssh_port,
|
||||
user: &mut c.ssh_username,
|
||||
auth: &mut c.ssh_auth_method,
|
||||
key: &mut c.ssh_private_key,
|
||||
agent: &mut c.ssh_agent_auth,
|
||||
mode: &mut c.ssh_secret_mode,
|
||||
},
|
||||
keys,
|
||||
key_checks,
|
||||
secrets,
|
||||
errs,
|
||||
);
|
||||
});
|
||||
section(ui, "Reconnection", |ui| reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries));
|
||||
}
|
||||
@@ -696,11 +752,13 @@ impl ProfileEditor {
|
||||
section(ui, "Line Settings", |ui| {
|
||||
label(ui, "Baud Rate");
|
||||
ui.horizontal(|ui| {
|
||||
ComboBox::from_id_salt("serial-baud").selected_text(if BAUDS.contains(&c.baud_rate) { c.baud_rate.to_string() } else { "Custom".into() }).show_ui(ui, |ui| {
|
||||
for b in BAUDS {
|
||||
ui.selectable_value(&mut c.baud_rate, b, b.to_string());
|
||||
}
|
||||
});
|
||||
ComboBox::from_id_salt("serial-baud")
|
||||
.selected_text(if BAUDS.contains(&c.baud_rate) { c.baud_rate.to_string() } else { "Custom".into() })
|
||||
.show_ui(ui, |ui| {
|
||||
for b in BAUDS {
|
||||
ui.selectable_value(&mut c.baud_rate, b, b.to_string());
|
||||
}
|
||||
});
|
||||
ui.add(DragValue::new(&mut c.baud_rate).range(1..=20_000_000)).on_hover_text("Any rate the adapter supports");
|
||||
});
|
||||
err(ui, errs, "serial.baudRate");
|
||||
@@ -709,7 +767,12 @@ impl ProfileEditor {
|
||||
combo(ui, "Parity", &mut c.parity, &[(Parity::None, "None"), (Parity::Odd, "Odd"), (Parity::Even, "Even")]);
|
||||
combo(ui, "Stop Bits", &mut c.stop_bits, &[(1, "1"), (2, "2")]);
|
||||
err(ui, errs, "serial.stopBits");
|
||||
combo(ui, "Flow Control", &mut c.flow_control, &[(FlowControl::None, "None"), (FlowControl::Xonxoff, "XON/XOFF (software)"), (FlowControl::Rtscts, "RTS/CTS (hardware)")]);
|
||||
combo(
|
||||
ui,
|
||||
"Flow Control",
|
||||
&mut c.flow_control,
|
||||
&[(FlowControl::None, "None"), (FlowControl::Xonxoff, "XON/XOFF (software)"), (FlowControl::Rtscts, "RTS/CTS (hardware)")],
|
||||
);
|
||||
});
|
||||
section(ui, "Behaviour", |ui| {
|
||||
line_ending(ui, &mut c.line_ending);
|
||||
@@ -799,11 +862,7 @@ fn prep(p: &mut Profile) {
|
||||
}
|
||||
|
||||
fn parse_env(text: &str) -> BTreeMap<String, String> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.split_once('='))
|
||||
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
|
||||
.filter(|(k, _)| !k.is_empty())
|
||||
.collect()
|
||||
text.lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).filter(|(k, _)| !k.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// "ssh.jumpHosts[2].host" → 2
|
||||
@@ -1059,7 +1118,9 @@ fn login_script(ui: &mut Ui, id: &str, steps: &mut Vec<LoginScriptStep>) {
|
||||
} else {
|
||||
ui.add_space(56.0 + ui.spacing().item_spacing.x);
|
||||
}
|
||||
ui.add(TextEdit::singleline(&mut st.value).hint_text(if wait { "Text to match…" } else { "Text to send…" }).desired_width(ui.available_width()));
|
||||
ui.add(
|
||||
TextEdit::singleline(&mut st.value).hint_text(if wait { "Text to match…" } else { "Text to send…" }).desired_width(ui.available_width()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,16 @@ pub struct QuickConnect {
|
||||
|
||||
impl Default for QuickConnect {
|
||||
fn default() -> Self {
|
||||
QuickConnect { open: false, focus: false, protocol: Protocol::Ssh, host: String::new(), port: 22, username: String::new(), nick: "nexus".into(), ports: vec![] }
|
||||
QuickConnect {
|
||||
open: false,
|
||||
focus: false,
|
||||
protocol: Protocol::Ssh,
|
||||
host: String::new(),
|
||||
port: 22,
|
||||
username: String::new(),
|
||||
nick: "nexus".into(),
|
||||
ports: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +106,8 @@ impl QuickConnect {
|
||||
let r = ui
|
||||
.horizontal(|ui| {
|
||||
let w = if serial { ui.available_width() - 34.0 } else { ui.available_width() };
|
||||
let r = ui.add(TextEdit::singleline(&mut self.host).hint_text(if serial { "/dev/ttyUSB0 or COM3" } else { "e.g. 192.168.1.1" }).desired_width(w));
|
||||
let r = ui
|
||||
.add(TextEdit::singleline(&mut self.host).hint_text(if serial { "/dev/ttyUSB0 or COM3" } else { "e.g. 192.168.1.1" }).desired_width(w));
|
||||
if serial {
|
||||
ui.menu_button(icon::CARET_DOWN, |ui| {
|
||||
if self.ports.is_empty() {
|
||||
|
||||
@@ -227,7 +227,9 @@ impl RawView {
|
||||
s.warn("Nothing to save");
|
||||
return;
|
||||
}
|
||||
let Some(path) = rfd::FileDialog::new().set_file_name("capture.json").add_filter("JSON", &["json"]).save_file() else { return };
|
||||
let Some(path) = rfd::FileDialog::new().set_file_name("capture.json").add_filter("JSON", &["json"]).save_file() else {
|
||||
return;
|
||||
};
|
||||
match s.svc.sessions.raw_save_capture(&self.session, &path) {
|
||||
Ok(()) => s.success("Capture saved"),
|
||||
Err(e) => s.error(format!("Save failed: {e}")),
|
||||
@@ -450,7 +452,9 @@ fn hex_job(line: &str, font: &FontId, t: &Tokens, fg: Color32) -> LayoutJob {
|
||||
/// HH:MM:SS for today, otherwise "Mon DD HH:MM:SS" (the web build's formatTimestamp).
|
||||
fn format_time(ms: i64) -> String {
|
||||
use chrono::{Local, TimeZone};
|
||||
let Some(d) = Local.timestamp_millis_opt(ms).single() else { return String::new() };
|
||||
let Some(d) = Local.timestamp_millis_opt(ms).single() else {
|
||||
return String::new();
|
||||
};
|
||||
if d.date_naive() == Local::now().date_naive() { d.format("%H:%M:%S").to_string() } else { d.format("%b %d %H:%M:%S").to_string() }
|
||||
}
|
||||
|
||||
|
||||
+31
-10
@@ -15,7 +15,7 @@ const SPEEDS: [f64; 5] = [0.5, 1.0, 2.0, 5.0, 10.0];
|
||||
|
||||
/// m:ss, as the JS `_formatSeconds`.
|
||||
pub fn mmss(secs: f64) -> String {
|
||||
if !(secs > 0.0) {
|
||||
if secs.is_nan() || secs <= 0.0 {
|
||||
return "0:00".into();
|
||||
}
|
||||
let s = secs.floor() as u64;
|
||||
@@ -97,7 +97,18 @@ pub struct Recordings {
|
||||
|
||||
impl Default for Recordings {
|
||||
fn default() -> Self {
|
||||
Recordings { open: false, list: vec![], info: None, loading: false, rx: None, age: 30, size: 1 << 30, status: String::new(), confirm_delete: None, playback: None }
|
||||
Recordings {
|
||||
open: false,
|
||||
list: vec![],
|
||||
info: None,
|
||||
loading: false,
|
||||
rx: None,
|
||||
age: 30,
|
||||
size: 1 << 30,
|
||||
status: String::new(),
|
||||
confirm_delete: None,
|
||||
playback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +161,8 @@ impl Recordings {
|
||||
}
|
||||
let total = events.last().map_or(1.0, |e| e.elapsed).max(f64::EPSILON);
|
||||
let title = if header.title.is_empty() { title } else { header.title };
|
||||
let mut p = Playback { title, term: TermView::new(10_000, 13.0, CursorStyle::Block), events, total, pos: 0.0, next: 0, playing: false, speed: 1.0 };
|
||||
let mut p =
|
||||
Playback { title, term: TermView::new(10_000, 13.0, CursorStyle::Block), events, total, pos: 0.0, next: 0, playing: false, speed: 1.0 };
|
||||
p.play(); // autoplay
|
||||
self.playback = Some(p);
|
||||
}
|
||||
@@ -222,7 +234,11 @@ impl Recordings {
|
||||
ui.set_width(ui.available_width() - 64.0);
|
||||
ui.label(RichText::new(if r.title.is_empty() { &r.id } else { &r.title }).font(theme::semibold(13.0)));
|
||||
let date = r.created_at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M");
|
||||
ui.label(RichText::new(format!("{date} · {} · {} · {}×{}", mmss(r.duration), widgets::human_size(r.size), r.width, r.height)).size(11.5).color(t.text_dim));
|
||||
ui.label(
|
||||
RichText::new(format!("{date} · {} · {} · {}×{}", mmss(r.duration), widgets::human_size(r.size), r.width, r.height))
|
||||
.size(11.5)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
if widgets::icon_button(ui, icon::PLAY, "Play").clicked() {
|
||||
play = Some(i);
|
||||
@@ -245,7 +261,9 @@ impl Recordings {
|
||||
}
|
||||
|
||||
fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
||||
let Some(r) = &self.confirm_delete else { return };
|
||||
let Some(r) = &self.confirm_delete else {
|
||||
return;
|
||||
};
|
||||
let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape)));
|
||||
let name = if r.title.is_empty() { r.id.clone() } else { r.title.clone() };
|
||||
crate::chrome::modal(ctx, "rec-delete", 380.0, |ui| {
|
||||
@@ -309,11 +327,14 @@ impl Recordings {
|
||||
});
|
||||
});
|
||||
});
|
||||
egui::Frame::NONE.fill(egui::Color32::from_rgba_unmultiplied(theme.background[0], theme.background[1], theme.background[2], 255)).corner_radius(t.radius).show(ui, |ui| {
|
||||
ui.set_min_size(ui.available_size());
|
||||
// Read-only: keyboard input and terminal replies are dropped.
|
||||
let _ = p.term.ui(ui, theme, false);
|
||||
});
|
||||
egui::Frame::NONE
|
||||
.fill(egui::Color32::from_rgba_unmultiplied(theme.background[0], theme.background[1], theme.background[2], 255))
|
||||
.corner_radius(t.radius)
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_size(ui.available_size());
|
||||
// Read-only: keyboard input and terminal replies are dropped.
|
||||
let _ = p.term.ui(ui, theme, false);
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
self.playback = None;
|
||||
|
||||
+73
-19
@@ -19,14 +19,27 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Worker results, applied on the UI thread.
|
||||
enum Done {
|
||||
Listed { session: String, path: String, files: Result<Vec<FileEntry>, String> },
|
||||
Picker { session: String, result: Result<(String, Vec<FileEntry>, bool), String> },
|
||||
Listed {
|
||||
session: String,
|
||||
path: String,
|
||||
files: Result<Vec<FileEntry>, String>,
|
||||
},
|
||||
Picker {
|
||||
session: String,
|
||||
result: Result<(String, Vec<FileEntry>, bool), String>,
|
||||
},
|
||||
Refresh(String),
|
||||
TransferEnded { session: String, path: String },
|
||||
TransferEnded {
|
||||
session: String,
|
||||
path: String,
|
||||
},
|
||||
/// Always shown.
|
||||
Toast(String),
|
||||
/// Shown only while the panel for `session` is open (otherwise per-file toasts cover it).
|
||||
Summary { session: String, text: String },
|
||||
Summary {
|
||||
session: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -372,10 +385,14 @@ impl Sftp {
|
||||
|
||||
fn download(&mut self, s: &Shared, session: &str, files: Vec<FileEntry>) {
|
||||
let targets: Vec<(FileEntry, PathBuf)> = if files.len() == 1 {
|
||||
let Some(p) = rfd::FileDialog::new().set_file_name(&files[0].name).save_file() else { return };
|
||||
let Some(p) = rfd::FileDialog::new().set_file_name(&files[0].name).save_file() else {
|
||||
return;
|
||||
};
|
||||
vec![(files[0].clone(), p)]
|
||||
} else {
|
||||
let Some(dir) = rfd::FileDialog::new().set_title("Download to folder").pick_folder() else { return };
|
||||
let Some(dir) = rfd::FileDialog::new().set_title("Download to folder").pick_folder() else {
|
||||
return;
|
||||
};
|
||||
files.into_iter().filter_map(|e| safe_sftp_upload_name(&e.name).ok().map(|n| (e, dir.join(n)))).collect()
|
||||
};
|
||||
for (e, _) in &targets {
|
||||
@@ -422,7 +439,14 @@ impl Sftp {
|
||||
}
|
||||
|
||||
/// Runs a single SFTP operation, then toasts `ok` and refreshes the listing.
|
||||
fn op(&mut self, s: &Shared, session: &str, ok: &'static str, what: &'static str, f: impl FnOnce(&Service, &str) -> nexus_core::Result<()> + Send + 'static) {
|
||||
fn op(
|
||||
&mut self,
|
||||
s: &Shared,
|
||||
session: &str,
|
||||
ok: &'static str,
|
||||
what: &'static str,
|
||||
f: impl FnOnce(&Service, &str) -> nexus_core::Result<()> + Send + 'static,
|
||||
) {
|
||||
let sid = session.to_string();
|
||||
self.work(s, move |svc, out| {
|
||||
match f(svc, &sid) {
|
||||
@@ -460,7 +484,9 @@ impl Sftp {
|
||||
/// The dock panel.
|
||||
pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) {
|
||||
self.drain(s);
|
||||
let Some(session) = self.open_for.clone() else { return };
|
||||
let Some(session) = self.open_for.clone() else {
|
||||
return;
|
||||
};
|
||||
if !self.panels.contains_key(&session) {
|
||||
self.navigate(s, &session, None);
|
||||
}
|
||||
@@ -535,7 +561,9 @@ impl Sftp {
|
||||
});
|
||||
});
|
||||
|
||||
let Some(p) = self.panels.get(&session) else { return };
|
||||
let Some(p) = self.panels.get(&session) else {
|
||||
return;
|
||||
};
|
||||
if let Some(go) = breadcrumb(ui, &p.cwd) {
|
||||
act = Some(Act::Nav(go));
|
||||
}
|
||||
@@ -555,7 +583,11 @@ impl Sftp {
|
||||
ui.add(egui::Label::new(base_name(&x.path)).truncate()).on_hover_text(&x.path);
|
||||
ui.add(egui::ProgressBar::new(frac).desired_height(4.0).fill(t.accent));
|
||||
let total = if x.total > 0 { widgets::human_size(x.total) } else { "?".into() };
|
||||
ui.label(RichText::new(format!("{} / {total} ({:.0}%)", widgets::human_size(x.transferred), frac * 100.0)).size(11.0).color(t.text_faint));
|
||||
ui.label(
|
||||
RichText::new(format!("{} / {total} ({:.0}%)", widgets::human_size(x.transferred), frac * 100.0))
|
||||
.size(11.0)
|
||||
.color(t.text_faint),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -595,7 +627,13 @@ impl Sftp {
|
||||
if p.selected.contains(&e.path) {
|
||||
ui.painter().rect_filled(r.expand2(vec2(6.0, 0.0)), 3.0, t.row_selected);
|
||||
}
|
||||
ui.painter().text(r.left_center() + vec2(8.0, 0.0), egui::Align2::CENTER_CENTER, file_icon(e), egui::FontId::proportional(14.0), if e.is_dir { t.accent } else { t.text_dim });
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(8.0, 0.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
file_icon(e),
|
||||
egui::FontId::proportional(14.0),
|
||||
if e.is_dir { t.accent } else { t.text_dim },
|
||||
);
|
||||
let meta_w = if e.is_dir { 0.0 } else { 72.0 };
|
||||
let nr = egui::Rect::from_min_max(egui::pos2(r.min.x + 22.0, r.min.y), egui::pos2(r.max.x - meta_w - 4.0, r.max.y));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(nr).layout(Layout::left_to_right(Align::Center)), |ui| {
|
||||
@@ -603,7 +641,13 @@ impl Sftp {
|
||||
});
|
||||
if !e.is_dir {
|
||||
let mr = egui::Rect::from_min_max(egui::pos2(r.max.x - meta_w, r.min.y), r.max);
|
||||
ui.painter().text(mr.right_center(), egui::Align2::RIGHT_CENTER, widgets::human_size(e.size), egui::FontId::proportional(11.5), t.text_faint);
|
||||
ui.painter().text(
|
||||
mr.right_center(),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
widgets::human_size(e.size),
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text_faint,
|
||||
);
|
||||
if e.modified_at > 0 {
|
||||
ui.interact(mr, ui.id().with(("meta", i)), Sense::hover()).on_hover_text(modified(e.modified_at));
|
||||
}
|
||||
@@ -718,14 +762,14 @@ impl Sftp {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- dialogs --------------------------------------------------------------------------
|
||||
|
||||
/// Modal dialogs (file drop, directory picker), drawn every frame.
|
||||
pub fn dialogs(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
||||
self.drain(s);
|
||||
let Some(mut d) = self.dialog.take() else { return };
|
||||
let Some(mut d) = self.dialog.take() else {
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let esc = ctx.input(|i| i.key_pressed(Key::Escape));
|
||||
let enter = ctx.input(|i| i.key_pressed(Key::Enter));
|
||||
@@ -802,7 +846,9 @@ impl Sftp {
|
||||
modal(ctx, "sftp-drop", 420.0, |ui| {
|
||||
ui.label(RichText::new(format!("{} File Drop", icon::FILE_ARROW_UP)).font(theme::semibold(15.0)));
|
||||
ui.label(RichText::new(title).strong());
|
||||
ui.label(RichText::new(format!("Choose what to do with the dropped {}.", if paths.len() == 1 { "file" } else { "files" })).color(t.text_dim));
|
||||
ui.label(
|
||||
RichText::new(format!("Choose what to do with the dropped {}.", if paths.len() == 1 { "file" } else { "files" })).color(t.text_dim),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
upload = ui
|
||||
@@ -894,8 +940,15 @@ impl Sftp {
|
||||
|
||||
// Keyboard: ↑↓ move (skipping unreadable folders), Enter uses, Backspace goes up.
|
||||
if ready {
|
||||
let (down, up, back) = ctx.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::ArrowUp), i.key_pressed(Key::Backspace)));
|
||||
let delta: isize = if down { 1 } else if up { -1 } else { 0 };
|
||||
let (down, up, back) = ctx
|
||||
.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::ArrowUp), i.key_pressed(Key::Backspace)));
|
||||
let delta: isize = if down {
|
||||
1
|
||||
} else if up {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if delta != 0 && !visible.is_empty() {
|
||||
let n = visible.len() as isize;
|
||||
let mut idx = pk.cursor.map_or(if delta > 0 { -1 } else { n }, |c| c as isize);
|
||||
@@ -1003,7 +1056,8 @@ impl Sftp {
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let suffix = if sel.is_none() && !pk.cwd_writable { " (read only)" } else { "" };
|
||||
ui.add(egui::Label::new(RichText::new(format!("{target}{suffix}")).font(theme::mono(11.5)).color(t.text_dim)).truncate()).on_hover_text(&target);
|
||||
ui.add(egui::Label::new(RichText::new(format!("{target}{suffix}")).font(theme::mono(11.5)).color(t.text_dim)).truncate())
|
||||
.on_hover_text(&target);
|
||||
let status = match (sel.is_some(), writable) {
|
||||
(_, true) => "",
|
||||
(true, false) => "No write permission",
|
||||
|
||||
+35
-7
@@ -74,7 +74,7 @@ fn list(app: &mut App, ui: &mut Ui) {
|
||||
fuzzy_score(&fields, &q).map(|s| (s, p.clone()))
|
||||
})
|
||||
.collect();
|
||||
hits.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
hits.sort_by_key(|h| std::cmp::Reverse(h.0));
|
||||
if hits.is_empty() {
|
||||
widgets::empty(ui, icon::MAGNIFYING_GLASS, "No matches", "Try a host, name, tag or protocol.");
|
||||
}
|
||||
@@ -91,7 +91,7 @@ fn list(app: &mut App, ui: &mut Ui) {
|
||||
section(app, ui, "★ Favourites", "favourites", &favs);
|
||||
}
|
||||
let mut recent: Vec<&Profile> = profiles.iter().filter(|p| p.last_connected.is_some()).collect();
|
||||
recent.sort_by(|a, b| b.last_connected.cmp(&a.last_connected));
|
||||
recent.sort_by_key(|p| std::cmp::Reverse(p.last_connected));
|
||||
recent.truncate(RECENT);
|
||||
if !recent.is_empty() {
|
||||
section(app, ui, "Recent", "recent", &recent);
|
||||
@@ -169,7 +169,13 @@ fn local_row(app: &mut App, ui: &mut Ui) {
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 6.0, t.hover);
|
||||
}
|
||||
ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, icon::TERMINAL_WINDOW, egui::FontId::proportional(15.0), crate::views::protocol_color(nexus_core::models::Protocol::Local));
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(10.0, 0.0),
|
||||
Align2::LEFT_CENTER,
|
||||
icon::TERMINAL_WINDOW,
|
||||
egui::FontId::proportional(15.0),
|
||||
crate::views::protocol_color(nexus_core::models::Protocol::Local),
|
||||
);
|
||||
ui.painter().text(r.left_center() + vec2(32.0, 0.0), Align2::LEFT_CENTER, "Local Machine", egui::FontId::proportional(13.0), t.text);
|
||||
if resp.on_hover_text("Open a terminal on this computer").clicked() {
|
||||
app.connect_local();
|
||||
@@ -204,7 +210,14 @@ fn row(app: &mut App, ui: &mut Ui, p: &Profile, section: &str) {
|
||||
if p.favourite {
|
||||
ui.painter().text(r.right_center() - vec2(36.0, 0.0), Align2::RIGHT_CENTER, "★", egui::FontId::proportional(11.0), t.warning);
|
||||
}
|
||||
let tip = format!("{}\n{} {}:{}{}", name, protocol_badge(p.protocol), p.host, p.port, if p.description.is_empty() { String::new() } else { format!("\n{}", p.description) });
|
||||
let tip = format!(
|
||||
"{}\n{} {}:{}{}",
|
||||
name,
|
||||
protocol_badge(p.protocol),
|
||||
p.host,
|
||||
p.port,
|
||||
if p.description.is_empty() { String::new() } else { format!("\n{}", p.description) }
|
||||
);
|
||||
let resp = resp.on_hover_text(tip);
|
||||
let multi = ui.input(|i| i.modifiers.command);
|
||||
if resp.double_clicked() {
|
||||
@@ -281,16 +294,31 @@ fn bulk_bar(app: &mut App, ui: &mut Ui) {
|
||||
}
|
||||
ui.menu_button(format!("{} Tags", icon::TAG), |ui| {
|
||||
if ui.button("Add tags…").clicked() {
|
||||
app.dialog = Some(Dialog::Input { title: "Add tags".into(), label: "Comma-separated tags to add".into(), value: String::new(), then: InputThen::Tags { profiles: ids.clone(), replace: false } });
|
||||
app.dialog = Some(Dialog::Input {
|
||||
title: "Add tags".into(),
|
||||
label: "Comma-separated tags to add".into(),
|
||||
value: String::new(),
|
||||
then: InputThen::Tags { profiles: ids.clone(), replace: false },
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Replace tags…").clicked() {
|
||||
app.dialog = Some(Dialog::Input { title: "Replace tags".into(), label: "Comma-separated tags".into(), value: String::new(), then: InputThen::Tags { profiles: ids.clone(), replace: true } });
|
||||
app.dialog = Some(Dialog::Input {
|
||||
title: "Replace tags".into(),
|
||||
label: "Comma-separated tags".into(),
|
||||
value: String::new(),
|
||||
then: InputThen::Tags { profiles: ids.clone(), replace: true },
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if ui.small_button(format!("{} Move group", icon::FOLDER)).clicked() {
|
||||
app.dialog = Some(Dialog::Input { title: "Move to group".into(), label: "Group name (empty for Ungrouped)".into(), value: String::new(), then: InputThen::MoveGroup { profiles: ids.clone() } });
|
||||
app.dialog = Some(Dialog::Input {
|
||||
title: "Move to group".into(),
|
||||
label: "Group name (empty for Ungrouped)".into(),
|
||||
value: String::new(),
|
||||
then: InputThen::MoveGroup { profiles: ids.clone() },
|
||||
});
|
||||
}
|
||||
if ui.small_button(format!("{} Delete", icon::TRASH)).clicked() {
|
||||
app.dialog = Some(Dialog::Delete { profiles: ids.clone() });
|
||||
|
||||
@@ -71,7 +71,15 @@ pub struct Snippets {
|
||||
|
||||
impl Default for Snippets {
|
||||
fn default() -> Self {
|
||||
Snippets { open: false, list: vec![], search: String::new(), pane: Pane::Empty("Select a snippet or create a new one"), confirm_delete: None, vars: None, loaded: false }
|
||||
Snippets {
|
||||
open: false,
|
||||
list: vec![],
|
||||
search: String::new(),
|
||||
pane: Pane::Empty("Select a snippet or create a new one"),
|
||||
confirm_delete: None,
|
||||
vars: None,
|
||||
loaded: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +271,9 @@ impl Snippets {
|
||||
|
||||
/// Resolves variables then types the command (plus newline) into the focused session.
|
||||
fn send(&mut self, s: &mut Shared, command: &str) {
|
||||
let Some(id) = s.focused_session() else { return s.error("No active session") };
|
||||
let Some(id) = s.focused_session() else {
|
||||
return s.error("No active session");
|
||||
};
|
||||
if s.session_state(&id) != Some(SessionState::Connected) {
|
||||
return s.error("Session is not connected");
|
||||
}
|
||||
@@ -306,7 +316,9 @@ impl Snippets {
|
||||
}
|
||||
|
||||
fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
||||
let Some(id) = self.confirm_delete.clone() else { return };
|
||||
let Some(id) = self.confirm_delete.clone() else {
|
||||
return;
|
||||
};
|
||||
let name = self.list.iter().find(|x| x.id == id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape)));
|
||||
crate::chrome::modal(ctx, "snippet-delete", 360.0, |ui| {
|
||||
@@ -335,7 +347,16 @@ impl Snippets {
|
||||
|
||||
fn blank() -> Snippet {
|
||||
let now = nexus_core::models::now();
|
||||
Snippet { id: String::new(), name: String::new(), command: String::new(), description: String::new(), folder: String::new(), tags: vec![], created_at: now, updated_at: now }
|
||||
Snippet {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
command: String::new(),
|
||||
description: String::new(),
|
||||
folder: String::new(),
|
||||
tags: vec![],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn send_text(s: &mut Shared, text: String) {
|
||||
|
||||
+40
-9
@@ -52,7 +52,13 @@ impl Split {
|
||||
|
||||
fn at(&mut self, path: &[bool]) -> &mut Split {
|
||||
match (self, path.split_first()) {
|
||||
(Split::Node { a, b, .. }, Some((side, rest))) => if *side { b.at(rest) } else { a.at(rest) },
|
||||
(Split::Node { a, b, .. }, Some((side, rest))) => {
|
||||
if *side {
|
||||
b.at(rest)
|
||||
} else {
|
||||
a.at(rest)
|
||||
}
|
||||
}
|
||||
(s, _) => s,
|
||||
}
|
||||
}
|
||||
@@ -124,20 +130,32 @@ fn divide(r: Rect, dir: Dir, ratio: f32) -> (Rect, Rect, Rect) {
|
||||
match dir {
|
||||
Dir::Right => {
|
||||
let x = r.left() + (r.width() - GAP) * ratio;
|
||||
(Rect::from_min_max(r.min, pos2(x, r.bottom())), Rect::from_min_max(pos2(x + GAP, r.top()), r.max), Rect::from_min_max(pos2(x, r.top()), pos2(x + GAP, r.bottom())))
|
||||
(
|
||||
Rect::from_min_max(r.min, pos2(x, r.bottom())),
|
||||
Rect::from_min_max(pos2(x + GAP, r.top()), r.max),
|
||||
Rect::from_min_max(pos2(x, r.top()), pos2(x + GAP, r.bottom())),
|
||||
)
|
||||
}
|
||||
Dir::Down => {
|
||||
let y = r.top() + (r.height() - GAP) * ratio;
|
||||
(Rect::from_min_max(r.min, pos2(r.right(), y)), Rect::from_min_max(pos2(r.left(), y + GAP), r.max), Rect::from_min_max(pos2(r.left(), y), pos2(r.right(), y + GAP)))
|
||||
(
|
||||
Rect::from_min_max(r.min, pos2(r.right(), y)),
|
||||
Rect::from_min_max(pos2(r.left(), y + GAP), r.max),
|
||||
Rect::from_min_max(pos2(r.left(), y), pos2(r.right(), y + GAP)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits the focused pane of the active tab.
|
||||
pub fn split_focused(app: &mut App, dir: Dir) {
|
||||
let Some(tab_id) = app.s.active.clone() else { return };
|
||||
let Some(tab_id) = app.s.active.clone() else {
|
||||
return;
|
||||
};
|
||||
let focused = app.s.focused_session().unwrap_or(tab_id.clone());
|
||||
let Some(tab) = app.s.tabs.iter_mut().find(|t| t.id == tab_id) else { return };
|
||||
let Some(tab) = app.s.tabs.iter_mut().find(|t| t.id == tab_id) else {
|
||||
return;
|
||||
};
|
||||
match &mut tab.split {
|
||||
None => tab.split = Some(Split::Node { dir, ratio: 0.5, a: Box::new(Split::Leaf(Some(tab_id.clone()))), b: Box::new(Split::Leaf(None)) }),
|
||||
Some(sp) => {
|
||||
@@ -152,7 +170,9 @@ pub fn split_focused(app: &mut App, dir: Dir) {
|
||||
/// removed too: the next pane takes over the tab.
|
||||
pub fn remove_session(s: &mut Shared, session: &str) {
|
||||
for i in 0..s.tabs.len() {
|
||||
let Some(sp) = &mut s.tabs[i].split else { continue };
|
||||
let Some(sp) = &mut s.tabs[i].split else {
|
||||
continue;
|
||||
};
|
||||
let mut path = vec![];
|
||||
if !sp.find(session, &mut path) {
|
||||
continue;
|
||||
@@ -202,7 +222,8 @@ pub fn render(app: &mut App, ui: &mut Ui, tab_id: &str) {
|
||||
};
|
||||
let min = (MIN_PANE / len).min(0.45);
|
||||
let ratio = ((pos - start) / len).clamp(min, 1.0 - min);
|
||||
if let Some(Split::Node { ratio: r, .. }) = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id).and_then(|tb| tb.split.as_mut()).map(|sp| sp.at(&path)) {
|
||||
if let Some(Split::Node { ratio: r, .. }) = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id).and_then(|tb| tb.split.as_mut()).map(|sp| sp.at(&path))
|
||||
{
|
||||
*r = ratio;
|
||||
}
|
||||
}
|
||||
@@ -213,9 +234,19 @@ pub fn render(app: &mut App, ui: &mut Ui, tab_id: &str) {
|
||||
let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max);
|
||||
let focused = session.is_some() && app.s.focused == session;
|
||||
ui.painter().rect_filled(head, 0.0, if focused { t.card } else { t.tab_strip });
|
||||
let title = session.as_deref().and_then(|s| app.s.pane(s)).map(|p| (p.info.tab_name.clone(), protocol_color(p.info.protocol))).unwrap_or(("New pane".into(), t.text_faint));
|
||||
let title = session
|
||||
.as_deref()
|
||||
.and_then(|s| app.s.pane(s))
|
||||
.map(|p| (p.info.tab_name.clone(), protocol_color(p.info.protocol)))
|
||||
.unwrap_or(("New pane".into(), t.text_faint));
|
||||
ui.painter().circle_filled(head.left_center() + vec2(10.0, 0.0), 3.5, title.1);
|
||||
ui.painter().with_clip_rect(head.shrink2(vec2(24.0, 0.0))).text(head.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, title.0, egui::FontId::proportional(11.5), if focused { t.text } else { t.text_dim });
|
||||
ui.painter().with_clip_rect(head.shrink2(vec2(24.0, 0.0))).text(
|
||||
head.left_center() + vec2(20.0, 0.0),
|
||||
Align2::LEFT_CENTER,
|
||||
title.0,
|
||||
egui::FontId::proportional(11.5),
|
||||
if focused { t.text } else { t.text_dim },
|
||||
);
|
||||
let xr = Rect::from_center_size(head.right_center() - vec2(12.0, 0.0), vec2(16.0, 16.0));
|
||||
if widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(xr)), icon::X, "Close pane").clicked() {
|
||||
close = Some((path.clone(), session.clone()));
|
||||
|
||||
@@ -90,7 +90,8 @@ impl SshKeys {
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add(egui::Label::new(RichText::new(&k.public_key).font(theme::mono(11.0)).color(t.text_dim)).truncate()).on_hover_text(&k.public_key);
|
||||
ui.add(egui::Label::new(RichText::new(&k.public_key).font(theme::mono(11.0)).color(t.text_dim)).truncate())
|
||||
.on_hover_text(&k.public_key);
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
@@ -111,7 +112,8 @@ impl SshKeys {
|
||||
s.error("Passphrases do not match");
|
||||
} else {
|
||||
let (svc, kind, bits, pass) = (s.svc.clone(), f.kind, f.bits, f.pass.clone());
|
||||
self.job = Some(Job::spawn(ctx, move || svc.generate_ssh_key(&name, kind, bits, &pass).map(|_| name).map_err(|e| e.to_string())));
|
||||
self.job =
|
||||
Some(Job::spawn(ctx, move || svc.generate_ssh_key(&name, kind, bits, &pass).map(|_| name).map_err(|e| e.to_string())));
|
||||
}
|
||||
}
|
||||
if self.job.is_none() && ui.button("Cancel").clicked() {
|
||||
@@ -125,7 +127,9 @@ impl SshKeys {
|
||||
}
|
||||
|
||||
fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
||||
let Some(name) = self.confirm_delete.clone() else { return };
|
||||
let Some(name) = self.confirm_delete.clone() else {
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let mut done = false;
|
||||
let r = egui::Modal::new(egui::Id::new("delete-key")).show(ctx, |ui| {
|
||||
|
||||
+6
-24
@@ -8,36 +8,27 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Things a feature asks the main window to do (handled once per frame, in order).
|
||||
#[allow(clippy::large_enum_variant)] // a per-frame queue; boxing buys nothing
|
||||
pub enum Request {
|
||||
/// Open the profile editor for a new profile (optionally pre-set protocol / folder).
|
||||
NewProfile { protocol: Option<Protocol>, folder: String },
|
||||
NewProfile {
|
||||
protocol: Option<Protocol>,
|
||||
folder: String,
|
||||
},
|
||||
EditProfile(String),
|
||||
Connect(String),
|
||||
ConnectQuick(Profile),
|
||||
ConnectLocal,
|
||||
/// Reload `Shared::profiles` from the database.
|
||||
ReloadProfiles,
|
||||
/// Re-read and apply preferences (theme, terminal theme, fonts…).
|
||||
ReloadPreferences,
|
||||
QuickConnect,
|
||||
Settings(SettingsTab),
|
||||
SshKeys,
|
||||
ExportEncrypted(Vec<String>),
|
||||
ImportEncrypted,
|
||||
ExportJson,
|
||||
ImportJson,
|
||||
Recordings,
|
||||
Snippets,
|
||||
Triggers,
|
||||
Sftp(String),
|
||||
PortForwards(String),
|
||||
/// Type text into the active session (snippets).
|
||||
SendToActive(String),
|
||||
/// Bytes to a specific session.
|
||||
Write(String, Vec<u8>),
|
||||
Disconnect(String),
|
||||
CommandPalette,
|
||||
About,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -174,11 +165,6 @@ impl Shared {
|
||||
self.panes.get_mut(id)
|
||||
}
|
||||
|
||||
/// The tab that shows `session` (its own, or the one it's split into).
|
||||
pub fn tab_of(&self, session: &str) -> Option<&Tab> {
|
||||
self.tabs.iter().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session)))
|
||||
}
|
||||
|
||||
/// The session keyboard actions apply to: the focused pane, else the active tab.
|
||||
pub fn focused_session(&self) -> Option<String> {
|
||||
self.focused.clone().or_else(|| self.active.clone())
|
||||
@@ -208,9 +194,5 @@ impl Shared {
|
||||
|
||||
/// Formats a command-key shortcut for menus: "⌘K" on macOS, "Ctrl+K" elsewhere.
|
||||
pub fn keys(s: &str) -> String {
|
||||
if cfg!(target_os = "macos") {
|
||||
s.replace("Mod+Shift+", "⇧⌘").replace("Mod+", "⌘")
|
||||
} else {
|
||||
s.replace("Mod+", "Ctrl+")
|
||||
}
|
||||
if cfg!(target_os = "macos") { s.replace("Mod+Shift+", "⇧⌘").replace("Mod+", "⌘") } else { s.replace("Mod+", "Ctrl+") }
|
||||
}
|
||||
|
||||
+15
-3
@@ -141,7 +141,13 @@ fn strip(app: &mut App, ui: &mut Ui) {
|
||||
if sftp {
|
||||
let fr = Rect::from_center_size(pos2(body.right() - 33.0, body.center().y), vec2(16.0, 16.0));
|
||||
let fresp = ui.interact(fr, ui.id().with(("sftp", id)), Sense::click());
|
||||
let c = if app.sftp.open_for() == Some(id.as_str()) { t.accent } else if fresp.hovered() { t.text } else { t.text_faint };
|
||||
let c = if app.sftp.open_for() == Some(id.as_str()) {
|
||||
t.accent
|
||||
} else if fresp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.text_faint
|
||||
};
|
||||
ui.painter().text(fr.center(), Align2::CENTER_CENTER, icon::FOLDER_SIMPLE, egui::FontId::proportional(12.0), c);
|
||||
if fresp.on_hover_text("SFTP browser").clicked() {
|
||||
app.s.request(Request::Sftp(id.clone()));
|
||||
@@ -211,7 +217,11 @@ fn strip(app: &mut App, ui: &mut Ui) {
|
||||
if widgets::icon_button(&mut right, icon::PLUS, "New connection").clicked() {
|
||||
app.s.request(Request::NewProfile { protocol: None, folder: String::new() });
|
||||
}
|
||||
let b = widgets::icon_button(&mut right, icon::BROADCAST, if app.s.broadcast { "Broadcast ON — click to stop, right-click for targets" } else { "Broadcast input (right-click to pick targets)" });
|
||||
let b = widgets::icon_button(
|
||||
&mut right,
|
||||
icon::BROADCAST,
|
||||
if app.s.broadcast { "Broadcast ON — click to stop, right-click for targets" } else { "Broadcast input (right-click to pick targets)" },
|
||||
);
|
||||
if app.s.broadcast {
|
||||
right.painter().rect_stroke(b.rect, 4.0, Stroke::new(1.5, t.warning), egui::StrokeKind::Inside);
|
||||
}
|
||||
@@ -281,7 +291,9 @@ fn tab_action(app: &mut App, id: &str, a: &str) {
|
||||
|
||||
/// Draws one session into `rect`.
|
||||
pub fn pane(app: &mut App, ui: &mut Ui, id: &str, rect: Rect) {
|
||||
let Some(mut pane) = app.s.panes.remove(id) else { return };
|
||||
let Some(mut pane) = app.s.panes.remove(id) else {
|
||||
return;
|
||||
};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let theme_id = app.s.prefs.terminal_theme.clone();
|
||||
let focused_tab = app.s.focused.as_deref() == Some(id) || (app.s.focused.is_none() && app.s.active.as_deref() == Some(id));
|
||||
|
||||
+16
-28
@@ -143,14 +143,6 @@ impl TermView {
|
||||
self.focus_requested = true;
|
||||
}
|
||||
|
||||
pub fn alt_screen(&self) -> bool {
|
||||
self.term.mode().contains(TermMode::ALT_SCREEN)
|
||||
}
|
||||
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.term.selection.as_ref().is_some_and(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn selection_text(&self) -> Option<String> {
|
||||
self.term.selection_to_string().filter(|s| !s.is_empty())
|
||||
}
|
||||
@@ -284,13 +276,13 @@ impl TermView {
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
out.focused = focused;
|
||||
if focused {
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
});
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }));
|
||||
self.keyboard(ui, &mut out);
|
||||
let cur = self.term.grid().cursor.point;
|
||||
let cursor_rect = Rect::from_min_size(origin + vec2(cur.column.0 as f32 * cw, cur.line.0 as f32 * ch), vec2(cw, ch));
|
||||
ui.output_mut(|o| o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Terminal, rect, cursor_rect, should_interrupt_composition: false }));
|
||||
ui.output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Terminal, rect, cursor_rect, should_interrupt_composition: false })
|
||||
});
|
||||
}
|
||||
self.mouse(ui, &resp, origin, cw, ch, &mut out);
|
||||
|
||||
@@ -475,7 +467,9 @@ impl TermView {
|
||||
continue;
|
||||
}
|
||||
if *pressed {
|
||||
if modifiers.command && let Some(url) = self.url_at(origin, cw, ch, *pos) {
|
||||
if modifiers.command
|
||||
&& let Some(url) = self.url_at(origin, cw, ch, *pos)
|
||||
{
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
continue;
|
||||
}
|
||||
@@ -560,11 +554,8 @@ impl TermView {
|
||||
let selection = self.term.selection.as_ref().and_then(|s| s.to_range(&self.term));
|
||||
let sel_bg = rgb(theme.selection);
|
||||
self.refresh_find();
|
||||
let finds: Vec<(i32, usize, usize, bool)> = self
|
||||
.find
|
||||
.as_ref()
|
||||
.map(|f| f.matches.iter().enumerate().map(|(i, &(l, a, b))| (l, a, b, i == f.current)).collect())
|
||||
.unwrap_or_default();
|
||||
let finds: Vec<(i32, usize, usize, bool)> =
|
||||
self.find.as_ref().map(|f| f.matches.iter().enumerate().map(|(i, &(l, a, b))| (l, a, b, i == f.current)).collect()).unwrap_or_default();
|
||||
let ncols = self.cols as usize;
|
||||
for vr in 0..self.rows as i32 {
|
||||
let line = Line(vr - offset);
|
||||
@@ -818,7 +809,7 @@ pub fn key_bytes(key: Key, m: Modifiers, mode: TermMode) -> Option<Vec<u8>> {
|
||||
Key::F10 => tilde(21),
|
||||
Key::F11 => tilde(23),
|
||||
Key::F12 => tilde(24),
|
||||
_ if m.ctrl && !m.command_only() => {
|
||||
_ if m.ctrl && !mac_cmd_only(m) => {
|
||||
let b = match key {
|
||||
Key::Space | Key::Num2 => 0,
|
||||
Key::OpenBracket | Key::Num3 => 0x1b,
|
||||
@@ -840,15 +831,9 @@ pub fn key_bytes(key: Key, m: Modifiers, mode: TermMode) -> Option<Vec<u8>> {
|
||||
})
|
||||
}
|
||||
|
||||
trait CommandOnly {
|
||||
fn command_only(&self) -> bool;
|
||||
}
|
||||
|
||||
impl CommandOnly for Modifiers {
|
||||
/// On macOS, Cmd is the app's modifier; Ctrl still produces control codes.
|
||||
fn command_only(&self) -> bool {
|
||||
cfg!(target_os = "macos") && self.mac_cmd && !self.ctrl
|
||||
}
|
||||
/// On macOS Cmd is the app's modifier while Ctrl still produces control codes.
|
||||
fn mac_cmd_only(m: Modifiers) -> bool {
|
||||
cfg!(target_os = "macos") && m.mac_cmd && !m.ctrl
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -862,6 +847,9 @@ mod tests {
|
||||
assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::APP_CURSOR).unwrap(), b"\x1bOA");
|
||||
assert_eq!(key_bytes(Key::ArrowLeft, Modifiers::CTRL, TermMode::empty()).unwrap(), b"\x1b[1;5D");
|
||||
assert_eq!(key_bytes(Key::C, Modifiers::CTRL, TermMode::empty()).unwrap(), [3]);
|
||||
// egui reports Ctrl as ctrl + command on Linux/Windows; it must still send a control code.
|
||||
let ctrl_cmd = Modifiers { ctrl: true, command: true, ..Default::default() };
|
||||
assert_eq!(key_bytes(Key::D, ctrl_cmd, TermMode::empty()).unwrap(), [4]);
|
||||
assert_eq!(key_bytes(Key::F5, none, TermMode::empty()).unwrap(), b"\x1b[15~");
|
||||
assert_eq!(key_bytes(Key::Backspace, none, TermMode::empty()).unwrap(), [0x7f]);
|
||||
assert!(key_bytes(Key::A, none, TermMode::empty()).is_none());
|
||||
|
||||
+198
-11
@@ -21,7 +21,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [78, 205, 196, 255],
|
||||
cursor_accent: [28, 28, 28, 255],
|
||||
selection: [78, 205, 196, 76],
|
||||
ansi: [[28, 28, 28, 255], [239, 83, 80, 255], [102, 187, 106, 255], [255, 167, 38, 255], [66, 165, 245, 255], [171, 71, 188, 255], [78, 205, 196, 255], [228, 228, 228, 255], [85, 85, 85, 255], [255, 112, 67, 255], [129, 199, 132, 255], [255, 204, 2, 255], [100, 181, 246, 255], [206, 147, 216, 255], [128, 222, 234, 255], [255, 255, 255, 255]],
|
||||
ansi: [
|
||||
[28, 28, 28, 255],
|
||||
[239, 83, 80, 255],
|
||||
[102, 187, 106, 255],
|
||||
[255, 167, 38, 255],
|
||||
[66, 165, 245, 255],
|
||||
[171, 71, 188, 255],
|
||||
[78, 205, 196, 255],
|
||||
[228, 228, 228, 255],
|
||||
[85, 85, 85, 255],
|
||||
[255, 112, 67, 255],
|
||||
[129, 199, 132, 255],
|
||||
[255, 204, 2, 255],
|
||||
[100, 181, 246, 255],
|
||||
[206, 147, 216, 255],
|
||||
[128, 222, 234, 255],
|
||||
[255, 255, 255, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "solarized-dark",
|
||||
@@ -31,7 +48,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [147, 161, 161, 255],
|
||||
cursor_accent: [0, 43, 54, 255],
|
||||
selection: [147, 161, 161, 76],
|
||||
ansi: [[7, 54, 66, 255], [220, 50, 47, 255], [133, 153, 0, 255], [181, 137, 0, 255], [38, 139, 210, 255], [211, 54, 130, 255], [42, 161, 152, 255], [238, 232, 213, 255], [88, 110, 117, 255], [203, 75, 22, 255], [88, 110, 117, 255], [101, 123, 131, 255], [131, 148, 150, 255], [108, 113, 196, 255], [147, 161, 161, 255], [253, 246, 227, 255]],
|
||||
ansi: [
|
||||
[7, 54, 66, 255],
|
||||
[220, 50, 47, 255],
|
||||
[133, 153, 0, 255],
|
||||
[181, 137, 0, 255],
|
||||
[38, 139, 210, 255],
|
||||
[211, 54, 130, 255],
|
||||
[42, 161, 152, 255],
|
||||
[238, 232, 213, 255],
|
||||
[88, 110, 117, 255],
|
||||
[203, 75, 22, 255],
|
||||
[88, 110, 117, 255],
|
||||
[101, 123, 131, 255],
|
||||
[131, 148, 150, 255],
|
||||
[108, 113, 196, 255],
|
||||
[147, 161, 161, 255],
|
||||
[253, 246, 227, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "monokai",
|
||||
@@ -41,7 +75,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [248, 248, 240, 255],
|
||||
cursor_accent: [39, 40, 34, 255],
|
||||
selection: [73, 72, 62, 153],
|
||||
ansi: [[39, 40, 34, 255], [249, 38, 114, 255], [166, 226, 46, 255], [244, 191, 117, 255], [102, 217, 239, 255], [174, 129, 255, 255], [161, 239, 228, 255], [248, 248, 242, 255], [117, 113, 94, 255], [249, 38, 114, 255], [166, 226, 46, 255], [244, 191, 117, 255], [102, 217, 239, 255], [174, 129, 255, 255], [161, 239, 228, 255], [249, 248, 245, 255]],
|
||||
ansi: [
|
||||
[39, 40, 34, 255],
|
||||
[249, 38, 114, 255],
|
||||
[166, 226, 46, 255],
|
||||
[244, 191, 117, 255],
|
||||
[102, 217, 239, 255],
|
||||
[174, 129, 255, 255],
|
||||
[161, 239, 228, 255],
|
||||
[248, 248, 242, 255],
|
||||
[117, 113, 94, 255],
|
||||
[249, 38, 114, 255],
|
||||
[166, 226, 46, 255],
|
||||
[244, 191, 117, 255],
|
||||
[102, 217, 239, 255],
|
||||
[174, 129, 255, 255],
|
||||
[161, 239, 228, 255],
|
||||
[249, 248, 245, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "nord",
|
||||
@@ -51,7 +102,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [216, 222, 233, 255],
|
||||
cursor_accent: [46, 52, 64, 255],
|
||||
selection: [136, 192, 208, 76],
|
||||
ansi: [[59, 66, 82, 255], [191, 97, 106, 255], [163, 190, 140, 255], [235, 203, 139, 255], [129, 161, 193, 255], [180, 142, 173, 255], [136, 192, 208, 255], [229, 233, 240, 255], [76, 86, 106, 255], [191, 97, 106, 255], [163, 190, 140, 255], [235, 203, 139, 255], [129, 161, 193, 255], [180, 142, 173, 255], [143, 188, 187, 255], [236, 239, 244, 255]],
|
||||
ansi: [
|
||||
[59, 66, 82, 255],
|
||||
[191, 97, 106, 255],
|
||||
[163, 190, 140, 255],
|
||||
[235, 203, 139, 255],
|
||||
[129, 161, 193, 255],
|
||||
[180, 142, 173, 255],
|
||||
[136, 192, 208, 255],
|
||||
[229, 233, 240, 255],
|
||||
[76, 86, 106, 255],
|
||||
[191, 97, 106, 255],
|
||||
[163, 190, 140, 255],
|
||||
[235, 203, 139, 255],
|
||||
[129, 161, 193, 255],
|
||||
[180, 142, 173, 255],
|
||||
[143, 188, 187, 255],
|
||||
[236, 239, 244, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "dracula",
|
||||
@@ -61,7 +129,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [248, 248, 242, 255],
|
||||
cursor_accent: [40, 42, 54, 255],
|
||||
selection: [68, 71, 90, 153],
|
||||
ansi: [[33, 34, 44, 255], [255, 85, 85, 255], [80, 250, 123, 255], [241, 250, 140, 255], [189, 147, 249, 255], [255, 121, 198, 255], [139, 233, 253, 255], [248, 248, 242, 255], [98, 114, 164, 255], [255, 110, 110, 255], [105, 255, 148, 255], [255, 255, 165, 255], [214, 172, 255, 255], [255, 146, 223, 255], [164, 255, 255, 255], [255, 255, 255, 255]],
|
||||
ansi: [
|
||||
[33, 34, 44, 255],
|
||||
[255, 85, 85, 255],
|
||||
[80, 250, 123, 255],
|
||||
[241, 250, 140, 255],
|
||||
[189, 147, 249, 255],
|
||||
[255, 121, 198, 255],
|
||||
[139, 233, 253, 255],
|
||||
[248, 248, 242, 255],
|
||||
[98, 114, 164, 255],
|
||||
[255, 110, 110, 255],
|
||||
[105, 255, 148, 255],
|
||||
[255, 255, 165, 255],
|
||||
[214, 172, 255, 255],
|
||||
[255, 146, 223, 255],
|
||||
[164, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "one-dark",
|
||||
@@ -71,7 +156,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [82, 139, 255, 255],
|
||||
cursor_accent: [40, 44, 52, 255],
|
||||
selection: [82, 139, 255, 76],
|
||||
ansi: [[40, 44, 52, 255], [224, 108, 117, 255], [152, 195, 121, 255], [229, 192, 123, 255], [97, 175, 239, 255], [198, 120, 221, 255], [86, 182, 194, 255], [171, 178, 191, 255], [92, 99, 112, 255], [224, 108, 117, 255], [152, 195, 121, 255], [229, 192, 123, 255], [97, 175, 239, 255], [198, 120, 221, 255], [86, 182, 194, 255], [255, 255, 255, 255]],
|
||||
ansi: [
|
||||
[40, 44, 52, 255],
|
||||
[224, 108, 117, 255],
|
||||
[152, 195, 121, 255],
|
||||
[229, 192, 123, 255],
|
||||
[97, 175, 239, 255],
|
||||
[198, 120, 221, 255],
|
||||
[86, 182, 194, 255],
|
||||
[171, 178, 191, 255],
|
||||
[92, 99, 112, 255],
|
||||
[224, 108, 117, 255],
|
||||
[152, 195, 121, 255],
|
||||
[229, 192, 123, 255],
|
||||
[97, 175, 239, 255],
|
||||
[198, 120, 221, 255],
|
||||
[86, 182, 194, 255],
|
||||
[255, 255, 255, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "gruvbox-dark",
|
||||
@@ -81,7 +183,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [235, 219, 178, 255],
|
||||
cursor_accent: [40, 40, 40, 255],
|
||||
selection: [214, 93, 14, 76],
|
||||
ansi: [[40, 40, 40, 255], [204, 36, 29, 255], [152, 151, 26, 255], [215, 153, 33, 255], [69, 133, 136, 255], [177, 98, 134, 255], [104, 157, 106, 255], [168, 153, 132, 255], [146, 131, 116, 255], [251, 73, 52, 255], [184, 187, 38, 255], [250, 189, 47, 255], [131, 165, 152, 255], [211, 134, 155, 255], [142, 192, 124, 255], [235, 219, 178, 255]],
|
||||
ansi: [
|
||||
[40, 40, 40, 255],
|
||||
[204, 36, 29, 255],
|
||||
[152, 151, 26, 255],
|
||||
[215, 153, 33, 255],
|
||||
[69, 133, 136, 255],
|
||||
[177, 98, 134, 255],
|
||||
[104, 157, 106, 255],
|
||||
[168, 153, 132, 255],
|
||||
[146, 131, 116, 255],
|
||||
[251, 73, 52, 255],
|
||||
[184, 187, 38, 255],
|
||||
[250, 189, 47, 255],
|
||||
[131, 165, 152, 255],
|
||||
[211, 134, 155, 255],
|
||||
[142, 192, 124, 255],
|
||||
[235, 219, 178, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "tokyo-night",
|
||||
@@ -91,7 +210,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [192, 202, 245, 255],
|
||||
cursor_accent: [26, 27, 38, 255],
|
||||
selection: [41, 46, 66, 230],
|
||||
ansi: [[21, 22, 30, 255], [247, 118, 142, 255], [158, 206, 106, 255], [224, 175, 104, 255], [122, 162, 247, 255], [187, 154, 247, 255], [125, 207, 255, 255], [169, 177, 214, 255], [65, 72, 104, 255], [247, 118, 142, 255], [158, 206, 106, 255], [224, 175, 104, 255], [122, 162, 247, 255], [187, 154, 247, 255], [125, 207, 255, 255], [192, 202, 245, 255]],
|
||||
ansi: [
|
||||
[21, 22, 30, 255],
|
||||
[247, 118, 142, 255],
|
||||
[158, 206, 106, 255],
|
||||
[224, 175, 104, 255],
|
||||
[122, 162, 247, 255],
|
||||
[187, 154, 247, 255],
|
||||
[125, 207, 255, 255],
|
||||
[169, 177, 214, 255],
|
||||
[65, 72, 104, 255],
|
||||
[247, 118, 142, 255],
|
||||
[158, 206, 106, 255],
|
||||
[224, 175, 104, 255],
|
||||
[122, 162, 247, 255],
|
||||
[187, 154, 247, 255],
|
||||
[125, 207, 255, 255],
|
||||
[192, 202, 245, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "catppuccin-mocha",
|
||||
@@ -101,7 +237,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [245, 224, 220, 255],
|
||||
cursor_accent: [30, 30, 46, 255],
|
||||
selection: [88, 91, 112, 128],
|
||||
ansi: [[69, 71, 90, 255], [243, 139, 168, 255], [166, 227, 161, 255], [249, 226, 175, 255], [137, 180, 250, 255], [245, 194, 231, 255], [148, 226, 213, 255], [186, 194, 222, 255], [88, 91, 112, 255], [243, 139, 168, 255], [166, 227, 161, 255], [249, 226, 175, 255], [137, 180, 250, 255], [245, 194, 231, 255], [148, 226, 213, 255], [166, 173, 200, 255]],
|
||||
ansi: [
|
||||
[69, 71, 90, 255],
|
||||
[243, 139, 168, 255],
|
||||
[166, 227, 161, 255],
|
||||
[249, 226, 175, 255],
|
||||
[137, 180, 250, 255],
|
||||
[245, 194, 231, 255],
|
||||
[148, 226, 213, 255],
|
||||
[186, 194, 222, 255],
|
||||
[88, 91, 112, 255],
|
||||
[243, 139, 168, 255],
|
||||
[166, 227, 161, 255],
|
||||
[249, 226, 175, 255],
|
||||
[137, 180, 250, 255],
|
||||
[245, 194, 231, 255],
|
||||
[148, 226, 213, 255],
|
||||
[166, 173, 200, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "rose-pine",
|
||||
@@ -111,7 +264,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [82, 79, 103, 255],
|
||||
cursor_accent: [224, 222, 244, 255],
|
||||
selection: [110, 106, 134, 102],
|
||||
ansi: [[38, 35, 58, 255], [235, 111, 146, 255], [49, 116, 143, 255], [246, 193, 119, 255], [156, 207, 216, 255], [196, 167, 231, 255], [235, 188, 186, 255], [224, 222, 244, 255], [110, 106, 134, 255], [235, 111, 146, 255], [49, 116, 143, 255], [246, 193, 119, 255], [156, 207, 216, 255], [196, 167, 231, 255], [235, 188, 186, 255], [224, 222, 244, 255]],
|
||||
ansi: [
|
||||
[38, 35, 58, 255],
|
||||
[235, 111, 146, 255],
|
||||
[49, 116, 143, 255],
|
||||
[246, 193, 119, 255],
|
||||
[156, 207, 216, 255],
|
||||
[196, 167, 231, 255],
|
||||
[235, 188, 186, 255],
|
||||
[224, 222, 244, 255],
|
||||
[110, 106, 134, 255],
|
||||
[235, 111, 146, 255],
|
||||
[49, 116, 143, 255],
|
||||
[246, 193, 119, 255],
|
||||
[156, 207, 216, 255],
|
||||
[196, 167, 231, 255],
|
||||
[235, 188, 186, 255],
|
||||
[224, 222, 244, 255],
|
||||
],
|
||||
},
|
||||
TermTheme {
|
||||
id: "solarized-light",
|
||||
@@ -121,7 +291,24 @@ pub const THEMES: &[TermTheme] = &[
|
||||
cursor: [88, 110, 117, 255],
|
||||
cursor_accent: [253, 246, 227, 255],
|
||||
selection: [7, 54, 66, 51],
|
||||
ansi: [[7, 54, 66, 255], [220, 50, 47, 255], [133, 153, 0, 255], [181, 137, 0, 255], [38, 139, 210, 255], [211, 54, 130, 255], [42, 161, 152, 255], [238, 232, 213, 255], [0, 43, 54, 255], [203, 75, 22, 255], [88, 110, 117, 255], [101, 123, 131, 255], [131, 148, 150, 255], [108, 113, 196, 255], [147, 161, 161, 255], [253, 246, 227, 255]],
|
||||
ansi: [
|
||||
[7, 54, 66, 255],
|
||||
[220, 50, 47, 255],
|
||||
[133, 153, 0, 255],
|
||||
[181, 137, 0, 255],
|
||||
[38, 139, 210, 255],
|
||||
[211, 54, 130, 255],
|
||||
[42, 161, 152, 255],
|
||||
[238, 232, 213, 255],
|
||||
[0, 43, 54, 255],
|
||||
[203, 75, 22, 255],
|
||||
[88, 110, 117, 255],
|
||||
[101, 123, 131, 255],
|
||||
[131, 148, 150, 255],
|
||||
[108, 113, 196, 255],
|
||||
[147, 161, 161, 255],
|
||||
[253, 246, 227, 255],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -212,7 +212,12 @@ pub fn install_fonts(ctx: &egui::Context) {
|
||||
/// The first wide-coverage (CJK) system font found on this machine.
|
||||
fn system_fallback_font() -> Option<Vec<u8>> {
|
||||
let candidates: &[&str] = if cfg!(target_os = "macos") {
|
||||
&["/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/Library/Fonts/Arial Unicode.ttf", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf"]
|
||||
&[
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
||||
]
|
||||
} else if cfg!(windows) {
|
||||
&["C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\YuGothM.ttc", "C:\\Windows\\Fonts\\msgothic.ttc", "C:\\Windows\\Fonts\\malgun.ttf"]
|
||||
} else {
|
||||
|
||||
+48
-11
@@ -14,10 +14,25 @@ const STRATEGIES: [(&str, &str); 3] = [("skip", "Skip existing"), ("overwrite",
|
||||
|
||||
enum Mode {
|
||||
/// `selected` is filled from `preselect` (or every profile) on the first frame.
|
||||
Export { preselect: Option<Vec<String>>, selected: HashSet<String>, pw: String, confirm: String },
|
||||
Import { path: PathBuf, pw: String, strategy: &'static str, status: String },
|
||||
ExportJson { include_secrets: bool },
|
||||
ImportJson { path: PathBuf, strategy: &'static str },
|
||||
Export {
|
||||
preselect: Option<Vec<String>>,
|
||||
selected: HashSet<String>,
|
||||
pw: String,
|
||||
confirm: String,
|
||||
},
|
||||
Import {
|
||||
path: PathBuf,
|
||||
pw: String,
|
||||
strategy: &'static str,
|
||||
status: String,
|
||||
},
|
||||
ExportJson {
|
||||
include_secrets: bool,
|
||||
},
|
||||
ImportJson {
|
||||
path: PathBuf,
|
||||
strategy: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
enum Done {
|
||||
@@ -38,7 +53,9 @@ impl Transfer {
|
||||
}
|
||||
|
||||
pub fn import_encrypted(&mut self) {
|
||||
if let Some(path) = rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION, "link"]).add_filter("All files", &["*"]).pick_file() {
|
||||
if let Some(path) =
|
||||
rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION, "link"]).add_filter("All files", &["*"]).pick_file()
|
||||
{
|
||||
self.mode = Some(Mode::Import { path, pw: String::new(), strategy: "skip", status: String::new() });
|
||||
}
|
||||
}
|
||||
@@ -112,7 +129,11 @@ impl Transfer {
|
||||
let name = if p.display_name.is_empty() { &p.host } else { &p.display_name };
|
||||
let label = format!("{name} ({})", p.protocol.id());
|
||||
if ui.checkbox(&mut on, label).changed() {
|
||||
if on { selected.insert(p.id.clone()) } else { selected.remove(&p.id) };
|
||||
if on {
|
||||
selected.insert(p.id.clone())
|
||||
} else {
|
||||
selected.remove(&p.id)
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -131,11 +152,18 @@ impl Transfer {
|
||||
s.error("Passwords do not match.");
|
||||
} else if selected.is_empty() {
|
||||
s.error("Please select at least one profile.");
|
||||
} else if let Some(path) = rfd::FileDialog::new().set_title("Export Profiles").set_file_name(format!("nexus-profiles.{EXTENSION}")).add_filter("Nexus Export", &[EXTENSION]).save_file() {
|
||||
} else if let Some(path) = rfd::FileDialog::new()
|
||||
.set_title("Export Profiles")
|
||||
.set_file_name(format!("nexus-profiles.{EXTENSION}"))
|
||||
.add_filter("Nexus Export", &[EXTENSION])
|
||||
.save_file()
|
||||
{
|
||||
// Keep the sidebar's order.
|
||||
let ids: Vec<String> = s.profiles.iter().filter(|p| selected.contains(&p.id)).map(|p| p.id.clone()).collect();
|
||||
let (svc, pw) = (s.svc.clone(), pw.clone());
|
||||
start = Some(Box::new(move || svc.export_encrypted(&ids, &pw, &path).map(|p| Done::Exported(ids.len(), p)).map_err(|e| format!("Export failed: {e}"))));
|
||||
start = Some(Box::new(move || {
|
||||
svc.export_encrypted(&ids, &pw, &path).map(|p| Done::Exported(ids.len(), p)).map_err(|e| format!("Export failed: {e}"))
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,10 +197,18 @@ impl Transfer {
|
||||
ui.label(format!("Exports all {} profile(s) as plain JSON.", s.profiles.len()));
|
||||
ui.checkbox(include_secrets, "Include secrets (proxy passwords, TOTP secrets)");
|
||||
if *include_secrets {
|
||||
ui.label(RichText::new(format!("{} The file will contain secrets in plain text. Prefer the encrypted export.", icon::WARNING)).color(t.warning).size(12.0));
|
||||
ui.label(
|
||||
RichText::new(format!("{} The file will contain secrets in plain text. Prefer the encrypted export.", icon::WARNING))
|
||||
.color(t.warning)
|
||||
.size(12.0),
|
||||
);
|
||||
}
|
||||
if buttons(ui, false, icon::FILE_ARROW_UP, "Export", &mut close)
|
||||
&& let Some(path) = rfd::FileDialog::new().set_title("Export Profiles (JSON)").set_file_name("nexus-profiles.json").add_filter("JSON", &["json"]).save_file()
|
||||
&& let Some(path) = rfd::FileDialog::new()
|
||||
.set_title("Export Profiles (JSON)")
|
||||
.set_file_name("nexus-profiles.json")
|
||||
.add_filter("JSON", &["json"])
|
||||
.save_file()
|
||||
{
|
||||
match s.svc.export_json(*include_secrets).map_err(|e| e.to_string()).and_then(|j| std::fs::write(&path, j).map_err(|e| e.to_string())) {
|
||||
Ok(()) => {
|
||||
@@ -189,7 +225,8 @@ impl Transfer {
|
||||
file_line(ui, path);
|
||||
strategy_combo(ui, strategy);
|
||||
if buttons(ui, false, icon::FILE_ARROW_DOWN, "Import", &mut close) {
|
||||
match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|j| s.svc.import_json(&j, strategy).map_err(|e| e.to_string())) {
|
||||
match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|j| s.svc.import_json(&j, strategy).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(n) => {
|
||||
s.success(format!("Imported {n} profile(s)"));
|
||||
s.request(Request::ReloadProfiles);
|
||||
|
||||
@@ -117,7 +117,10 @@ impl Triggers {
|
||||
}
|
||||
let lines = complete_lines(self.buffers.entry(session.to_owned()).or_default(), data);
|
||||
let mut highlight = false;
|
||||
let label = s.pane(session).map(|p| if p.info.tab_name.is_empty() { p.info.profile_name.clone() } else { p.info.tab_name.clone() }).unwrap_or_else(|| session.to_owned());
|
||||
let label = s
|
||||
.pane(session)
|
||||
.map(|p| if p.info.tab_name.is_empty() { p.info.profile_name.clone() } else { p.info.tab_name.clone() })
|
||||
.unwrap_or_else(|| session.to_owned());
|
||||
let mut fired: Vec<(String, String)> = vec![];
|
||||
for line in &lines {
|
||||
let clean = strip_ansi(line);
|
||||
@@ -191,7 +194,11 @@ impl Triggers {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new(if r.name.is_empty() { &r.pattern } else { &r.name }).font(theme::semibold(13.0)));
|
||||
let action = ACTIONS.iter().find(|a| a.0 == r.action).map_or(r.action.as_str(), |a| a.1);
|
||||
let meta = format!("{} · {action} · {}", if r.is_regex { "Regex" } else { "Text" }, if r.case_sensitive { "Case-sensitive" } else { "Case-insensitive" });
|
||||
let meta = format!(
|
||||
"{} · {action} · {}",
|
||||
if r.is_regex { "Regex" } else { "Text" },
|
||||
if r.case_sensitive { "Case-sensitive" } else { "Case-insensitive" }
|
||||
);
|
||||
ui.label(RichText::new(meta).size(11.5).color(t.text_dim));
|
||||
});
|
||||
ui.label(RichText::new(&r.pattern).font(theme::mono(12.0)).color(if r.is_regex && r.re.is_none() { t.danger } else { t.text }));
|
||||
|
||||
@@ -14,15 +14,6 @@ pub enum View {
|
||||
Vnc(Box<VncView>),
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn terminal(&mut self) -> Option<&mut TermView> {
|
||||
match self {
|
||||
View::Terminal(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol badge colour (the web build's --protocol-* tokens).
|
||||
pub fn protocol_color(p: nexus_core::models::Protocol) -> egui::Color32 {
|
||||
use nexus_core::models::Protocol::*;
|
||||
|
||||
@@ -254,7 +254,11 @@ impl VncView {
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_enabled_ui(self.ready, |ui| {
|
||||
let (g, tip) = if self.scale_to_fit { (icon::CORNERS_IN, "Scaled to fit (click for 1:1)") } else { (icon::CORNERS_OUT, "1:1 (click to scale to fit)") };
|
||||
let (g, tip) = if self.scale_to_fit {
|
||||
(icon::CORNERS_IN, "Scaled to fit (click for 1:1)")
|
||||
} else {
|
||||
(icon::CORNERS_OUT, "1:1 (click to scale to fit)")
|
||||
};
|
||||
if widgets::icon_button(ui, g, tip).clicked() {
|
||||
self.scale_to_fit = !self.scale_to_fit;
|
||||
}
|
||||
@@ -264,7 +268,10 @@ impl VncView {
|
||||
let _ = c.request_full_update();
|
||||
}
|
||||
if !self.view_only {
|
||||
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard").on_hover_text("Send the local clipboard to the remote desktop").clicked() {
|
||||
if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard")
|
||||
.on_hover_text("Send the local clipboard to the remote desktop")
|
||||
.clicked()
|
||||
{
|
||||
match arboard::Clipboard::new().and_then(|mut c| c.get_text()) {
|
||||
Ok(text) if !text.is_empty() => {
|
||||
self.paste(s, &text);
|
||||
@@ -293,9 +300,7 @@ impl VncView {
|
||||
let focused = resp.has_focus();
|
||||
if focused {
|
||||
ui.painter().rect_stroke(img, 0.0, egui::Stroke::new(1.0, Tokens::get(ui.ctx()).accent.gamma_multiply(0.6)), egui::StrokeKind::Outside);
|
||||
ui.memory_mut(|m| {
|
||||
m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
|
||||
});
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }));
|
||||
} else if self.had_focus {
|
||||
self.release_all();
|
||||
}
|
||||
@@ -344,7 +349,8 @@ impl VncView {
|
||||
for e in events {
|
||||
match e {
|
||||
Event::Key { key, pressed, modifiers, .. } => {
|
||||
let sym = named_keysym(key).or_else(|| (modifiers.ctrl || modifiers.alt || modifiers.mac_cmd).then(|| chord_keysym(key, modifiers.shift)).flatten());
|
||||
let sym = named_keysym(key)
|
||||
.or_else(|| (modifiers.ctrl || modifiers.alt || modifiers.mac_cmd).then(|| chord_keysym(key, modifiers.shift)).flatten());
|
||||
if let Some(sym) = sym {
|
||||
self.key(sym, pressed);
|
||||
}
|
||||
|
||||
@@ -70,11 +70,7 @@ struct ApiPerson {
|
||||
impl Client {
|
||||
/// `base_url` is the API root, e.g. `https://api.github.com` or `https://ghe.example.com/api/v3`.
|
||||
pub fn new(base_url: &str, token: &str) -> Client {
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.into();
|
||||
let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into();
|
||||
Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent }
|
||||
}
|
||||
|
||||
@@ -89,15 +85,12 @@ impl Client {
|
||||
|
||||
/// Confirms the token works and returns the authenticated login.
|
||||
pub fn validate_token(&self) -> crate::Result<String> {
|
||||
let mut resp = self
|
||||
.get(&format!("{}/user", self.base_url))
|
||||
.map_err(|e| format!("contacting GitHub API: {e}"))?;
|
||||
let mut resp = self.get(&format!("{}/user", self.base_url)).map_err(|e| format!("contacting GitHub API: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status != 200 {
|
||||
return Err(format!("token validation failed (HTTP {status})").into());
|
||||
}
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading user response: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading user response: {e}"))?;
|
||||
#[derive(Deserialize)]
|
||||
struct User {
|
||||
#[serde(default)]
|
||||
@@ -114,35 +107,21 @@ impl Client {
|
||||
|
||||
/// Like `fetch_repos`, reporting commit-enrichment progress as `(done, total)`; it is called
|
||||
/// from worker threads.
|
||||
pub fn fetch_repos_with_progress(
|
||||
&self,
|
||||
progress: impl Fn(usize, usize) + Sync,
|
||||
) -> crate::Result<Vec<GitHubRepo>> {
|
||||
pub fn fetch_repos_with_progress(&self, progress: impl Fn(usize, usize) + Sync) -> crate::Result<Vec<GitHubRepo>> {
|
||||
let mut all: Vec<ApiRepo> = Vec::new();
|
||||
let mut page = 1;
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc",
|
||||
self.base_url
|
||||
);
|
||||
let url = format!("{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc", self.base_url);
|
||||
let mut resp = self.get(&url).map_err(|e| format!("fetching repos page {page}: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading response body: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading response body: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status == 401 || status == 403 {
|
||||
return Err(
|
||||
format!("authentication failed (HTTP {status}) — check your access token").into()
|
||||
);
|
||||
return Err(format!("authentication failed (HTTP {status}) — check your access token").into());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
)
|
||||
.into());
|
||||
return Err(format!("GitHub API returned HTTP {status}: {}", String::from_utf8_lossy(&body)).into());
|
||||
}
|
||||
let batch: Vec<ApiRepo> =
|
||||
serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
let batch: Vec<ApiRepo> = serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -161,7 +140,7 @@ impl Client {
|
||||
let updated = parse_time(r.updated_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?;
|
||||
parsed.push((r, pushed, updated));
|
||||
}
|
||||
parsed.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
parsed.sort_by_key(|p| std::cmp::Reverse(p.1));
|
||||
|
||||
let mut result: Vec<GitHubRepo> = parsed
|
||||
.into_iter()
|
||||
@@ -234,14 +213,9 @@ impl Client {
|
||||
return Err(format!("authentication failed (HTTP {status})").into());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
read_error_body(resp.body_mut().as_reader())
|
||||
)
|
||||
.into());
|
||||
return Err(format!("GitHub API returned HTTP {status}: {}", read_error_body(resp.body_mut().as_reader())).into());
|
||||
}
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES)
|
||||
.map_err(|e| format!("reading commit response: {e}"))?;
|
||||
let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading commit response: {e}"))?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
@@ -284,25 +258,14 @@ impl Client {
|
||||
return Err(format!("authentication failed (HTTP {status}) — check your access token").into());
|
||||
}
|
||||
if status != 200 {
|
||||
return Err(format!(
|
||||
"GitHub API returned HTTP {status}: {}",
|
||||
read_error_body(resp.body_mut().as_reader())
|
||||
)
|
||||
.into());
|
||||
return Err(format!("GitHub API returned HTTP {status}: {}", read_error_body(resp.body_mut().as_reader())).into());
|
||||
}
|
||||
let length = resp
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.trim().parse::<u64>().ok());
|
||||
let length = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
|
||||
if let Some(length) = length.filter(|&l| l > MAX_ARCHIVE_BYTES) {
|
||||
return Err(
|
||||
format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into()
|
||||
);
|
||||
return Err(format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into());
|
||||
}
|
||||
let mut file = std::fs::File::create(dest_path).map_err(|e| format!("creating file: {e}"))?;
|
||||
copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES)
|
||||
.map_err(|e| format!("writing archive: {e}"))?;
|
||||
copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES).map_err(|e| format!("writing archive: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -446,9 +409,7 @@ mod tests {
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for req in server.incoming_requests() {
|
||||
let header = |name: &'static str| {
|
||||
req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string())
|
||||
};
|
||||
let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string());
|
||||
log.lock().unwrap().push((req.url().to_string(), header("Authorization"), header("User-Agent")));
|
||||
let (status, body) = handler(req.url());
|
||||
let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status));
|
||||
@@ -475,7 +436,7 @@ mod tests {
|
||||
serde_json::json!({
|
||||
"full_name": format!("me/r{n}"), "name": format!("r{n}"), "description": null,
|
||||
"html_url": format!("https://example/me/r{n}"), "default_branch": if n == 0 { "" } else { "main" },
|
||||
"private": n % 2 == 0, "fork": false, "archived": false, "language": null,
|
||||
"private": n.is_multiple_of(2), "fork": false, "archived": false, "language": null,
|
||||
"stargazers_count": n, "pushed_at": pushed, "updated_at": "2024-01-01T00:00:00Z",
|
||||
"owner": {"login": "me"}
|
||||
})
|
||||
@@ -485,9 +446,7 @@ mod tests {
|
||||
fn fetch_repos_paginates_sorts_and_enriches() {
|
||||
let (base, seen) = serve(|url| {
|
||||
if url.starts_with("/user/repos") && url.contains("page=1&") {
|
||||
let repos: Vec<_> = (0..100)
|
||||
.map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28)))
|
||||
.collect();
|
||||
let repos: Vec<_> = (0..100).map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28))).collect();
|
||||
(200, serde_json::to_string(&repos).unwrap())
|
||||
} else if url.starts_with("/user/repos") {
|
||||
(200, serde_json::to_string(&[repo_json(100, "2025-06-01T12:00:00Z")]).unwrap())
|
||||
@@ -500,9 +459,7 @@ mod tests {
|
||||
}
|
||||
});
|
||||
let calls = Mutex::new(Vec::new());
|
||||
let repos = Client::new(&base, "t")
|
||||
.fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total)))
|
||||
.unwrap();
|
||||
let repos = Client::new(&base, "t").fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total))).unwrap();
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[0].full_name, "me/r100");
|
||||
assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z");
|
||||
|
||||
@@ -478,14 +478,14 @@ impl Client {
|
||||
st.connected = false;
|
||||
r
|
||||
};
|
||||
if let Err(e) = result {
|
||||
if !was_closing {
|
||||
let msg = e.to_string();
|
||||
self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() });
|
||||
(self.cfg.on_error)(msg.clone());
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(msg));
|
||||
}
|
||||
if let Err(e) = result
|
||||
&& !was_closing
|
||||
{
|
||||
let msg = e.to_string();
|
||||
self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() });
|
||||
(self.cfg.on_error)(msg.clone());
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(msg));
|
||||
}
|
||||
}
|
||||
if was_connected {
|
||||
@@ -498,8 +498,7 @@ impl Client {
|
||||
Some(connector) => connector()?,
|
||||
None => {
|
||||
let addr = format!("{}:{}", self.cfg.server, self.cfg.port);
|
||||
let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout)
|
||||
.map_err(|e| format!("dial tcp {addr}: {e}"))?;
|
||||
let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout).map_err(|e| format!("dial tcp {addr}: {e}"))?;
|
||||
tcp.set_read_timeout(Some(READ_POLL))?;
|
||||
tcp.set_write_timeout(Some(self.cfg.connect_timeout))?;
|
||||
let _ = tcp.set_nodelay(true);
|
||||
@@ -571,10 +570,7 @@ impl Client {
|
||||
if ping_sent && self.st().last_pong.elapsed() > PING_DELAY + PING_TIMEOUT {
|
||||
return Err("timed out waiting for a requested PING response".into());
|
||||
}
|
||||
let id = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let id = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
|
||||
write(&mut stream, &encode("PING", &[&id.to_string()]))?;
|
||||
last_ping = Instant::now();
|
||||
ping_sent = true;
|
||||
@@ -637,11 +633,7 @@ impl Client {
|
||||
}
|
||||
"322" => {
|
||||
if m.params.len() >= 4 {
|
||||
let room = RoomInfo {
|
||||
name: m.param(1).into(),
|
||||
users: m.param(2).parse().unwrap_or(0),
|
||||
topic: m.param(3).into(),
|
||||
};
|
||||
let room = RoomInfo { name: m.param(1).into(), users: m.param(2).parse().unwrap_or(0), topic: m.param(3).into() };
|
||||
let mut st = self.st();
|
||||
if st.list_rooms.len() < MAX_ROOM_LIST_ENTRIES {
|
||||
st.list_rooms.push(room);
|
||||
@@ -668,11 +660,7 @@ impl Client {
|
||||
st.ready.take()
|
||||
};
|
||||
let host = if self.cfg.server.contains(':') { format!("[{}]", self.cfg.server) } else { self.cfg.server.clone() };
|
||||
self.emit(Event {
|
||||
kind: "system".into(),
|
||||
text: format!("Connected to {host}:{}", self.cfg.port),
|
||||
..Default::default()
|
||||
});
|
||||
self.emit(Event { kind: "system".into(), text: format!("Connected to {host}:{}", self.cfg.port), ..Default::default() });
|
||||
self.identify_with_nickserv();
|
||||
if !self.cfg.auto_join_rooms.is_empty() {
|
||||
let _ = self.send("JOIN", &[&self.cfg.auto_join_rooms.join(",")]);
|
||||
@@ -747,11 +735,7 @@ impl Client {
|
||||
let from = m.source.clone();
|
||||
if !from.is_empty() {
|
||||
let mut st = self.st();
|
||||
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel {
|
||||
name: room.clone(),
|
||||
topic: String::new(),
|
||||
users: Vec::new(),
|
||||
});
|
||||
let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { name: room.clone(), topic: String::new(), users: Vec::new() });
|
||||
add_user(ch, &from);
|
||||
}
|
||||
if self.is_me(&from) {
|
||||
@@ -856,14 +840,7 @@ impl Client {
|
||||
if let Some(ch) = self.st().channels.get_mut(&fold(room)) {
|
||||
ch.topic = topic.to_string();
|
||||
}
|
||||
self.emit(Event {
|
||||
kind: "topic".into(),
|
||||
target: room.into(),
|
||||
from: m.source.clone(),
|
||||
text: topic.into(),
|
||||
topic: topic.into(),
|
||||
..Default::default()
|
||||
});
|
||||
self.emit(Event { kind: "topic".into(), target: room.into(), from: m.source.clone(), text: topic.into(), topic: topic.into(), ..Default::default() });
|
||||
self.emit_rooms();
|
||||
}
|
||||
|
||||
@@ -1060,10 +1037,7 @@ mod tests {
|
||||
fn remember_room_membership_tracks_confirmed_channels() {
|
||||
let updates = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = updates.clone();
|
||||
let client = Client::new(Config {
|
||||
on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))),
|
||||
..Default::default()
|
||||
});
|
||||
let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))), ..Default::default() });
|
||||
client.remember_room_membership("#One", true);
|
||||
client.remember_room_membership("#two", true);
|
||||
client.remember_room_membership("#one", true);
|
||||
@@ -1095,11 +1069,7 @@ mod tests {
|
||||
fn event_serialises_like_go() {
|
||||
let e = Event { kind: "users".into(), target: "#a".into(), timestamp: 5, ..Default::default() };
|
||||
assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"users","target":"#a","timestamp":5}"##);
|
||||
let e = Event {
|
||||
kind: "rooms".into(),
|
||||
rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }],
|
||||
..Default::default()
|
||||
};
|
||||
let e = Event { kind: "rooms".into(), rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }], ..Default::default() };
|
||||
assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"rooms","timestamp":0,"rooms":[{"name":"#a"}]}"##);
|
||||
}
|
||||
|
||||
@@ -1236,10 +1206,7 @@ mod tests {
|
||||
srv.expect("NAMES #a");
|
||||
client.handle_input("#a", "/mode #a +i").unwrap();
|
||||
srv.expect("MODE #a +i");
|
||||
assert_eq!(
|
||||
client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(),
|
||||
"select a room or use /msg <nick> <message>"
|
||||
);
|
||||
assert_eq!(client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(), "select a room or use /msg <nick> <message>");
|
||||
assert_eq!(client.handle_input("#a", "/msg bob").unwrap_err().to_string(), "/msg requires a target and message");
|
||||
client.handle_input("", "/help").unwrap();
|
||||
assert!(wait_for(&rx, |e| e.kind == "system").text.starts_with("Commands: /join"));
|
||||
@@ -1269,10 +1236,7 @@ mod tests {
|
||||
fn remote_close_reports_error_and_close() {
|
||||
let errors = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = errors.clone();
|
||||
let (client, listener, rx, closes) = harness(Config {
|
||||
on_error: Arc::new(move |e| log.lock().unwrap().push(e)),
|
||||
..Default::default()
|
||||
});
|
||||
let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().unwrap().push(e)), ..Default::default() });
|
||||
let c = client.clone();
|
||||
let connecting = std::thread::spawn(move || c.connect());
|
||||
let mut srv = accept(&listener);
|
||||
|
||||
@@ -9,9 +9,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
pub fn default_shell() -> String {
|
||||
if cfg!(windows) {
|
||||
let ps = std::env::var_os("SystemRoot")
|
||||
.map(|r| PathBuf::from(r).join(r"System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||
.filter(|p| p.exists());
|
||||
let ps = std::env::var_os("SystemRoot").map(|r| PathBuf::from(r).join(r"System32\WindowsPowerShell\v1.0\powershell.exe")).filter(|p| p.exists());
|
||||
return ps.map_or_else(|| "cmd.exe".into(), |p| p.to_string_lossy().into());
|
||||
}
|
||||
std::env::var("SHELL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "/bin/sh".into())
|
||||
|
||||
@@ -17,9 +17,7 @@ use std::sync::{Arc, OnceLock};
|
||||
/// The shared tokio runtime for async protocol stacks (russh). Blocking clients use threads.
|
||||
pub fn rt() -> &'static tokio::runtime::Runtime {
|
||||
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
|
||||
RT.get_or_init(|| {
|
||||
tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime")
|
||||
})
|
||||
RT.get_or_init(|| tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime"))
|
||||
}
|
||||
|
||||
pub type DataFn = Arc<dyn Fn(&[u8]) + Send + Sync>;
|
||||
@@ -81,11 +79,7 @@ impl TcpConn {
|
||||
TcpConn { stream: std::sync::Mutex::new(None), on_error, on_close }
|
||||
}
|
||||
|
||||
pub(crate) fn connect(
|
||||
self: &Arc<Self>,
|
||||
stream: std::net::TcpStream,
|
||||
mut on_read: impl FnMut(&[u8]) + Send + 'static,
|
||||
) -> crate::Result<()> {
|
||||
pub(crate) fn connect(self: &Arc<Self>, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> {
|
||||
let mut reader = stream.try_clone()?;
|
||||
{
|
||||
let mut guard = self.stream.lock().unwrap();
|
||||
@@ -148,10 +142,7 @@ pub(crate) fn is_latin1(encoding: &str) -> bool {
|
||||
/// UTF-8 → ISO 8859-1. Like Go's charmap encoder, input that isn't valid UTF-8 or holds a
|
||||
/// character above U+00FF is returned unchanged.
|
||||
pub(crate) fn utf8_to_latin1(data: &[u8]) -> Vec<u8> {
|
||||
std::str::from_utf8(data)
|
||||
.ok()
|
||||
.and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect())
|
||||
.unwrap_or_else(|| data.to_vec())
|
||||
std::str::from_utf8(data).ok().and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect()).unwrap_or_else(|| data.to_vec())
|
||||
}
|
||||
|
||||
/// ISO 8859-1 → UTF-8 (every byte maps to the code point of the same value).
|
||||
|
||||
@@ -262,10 +262,7 @@ pub fn parse_escaped(s: &str) -> crate::Result<Vec<u8>> {
|
||||
/// "latin1" / "latin-1", otherwise UTF-8 with invalid bytes replaced by U+FFFD.
|
||||
pub fn to_display_encoding(data: &[u8], encoding: &str) -> String {
|
||||
match encoding.trim().to_ascii_lowercase().as_str() {
|
||||
"ascii" => data
|
||||
.iter()
|
||||
.map(|&b| if (0x20..=0x7e).contains(&b) || matches!(b, b'\n' | b'\r' | b'\t') { b as char } else { '.' })
|
||||
.collect(),
|
||||
"ascii" => data.iter().map(|&b| if (0x20..=0x7e).contains(&b) || matches!(b, b'\n' | b'\r' | b'\t') { b as char } else { '.' }).collect(),
|
||||
e if super::is_latin1(e) => super::latin1_to_utf8(data),
|
||||
_ => String::from_utf8_lossy(data).into_owned(),
|
||||
}
|
||||
@@ -285,13 +282,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_hex_string_cases() {
|
||||
let ok: [(&str, &[u8]); 5] = [
|
||||
("48656c6c6f", b"Hello"),
|
||||
("48 65 6c 6c 6f", b"Hello"),
|
||||
("0x48 0x65 0x6c 0x6c 0x6f", b"Hello"),
|
||||
("0000", &[0, 0]),
|
||||
("ff ff", &[0xff, 0xff]),
|
||||
];
|
||||
let ok: [(&str, &[u8]); 5] =
|
||||
[("48656c6c6f", b"Hello"), ("48 65 6c 6c 6f", b"Hello"), ("0x48 0x65 0x6c 0x6c 0x6f", b"Hello"), ("0000", &[0, 0]), ("ff ff", &[0xff, 0xff])];
|
||||
for (input, want) in ok {
|
||||
assert_eq!(parse_hex_string(input).unwrap(), want, "{input:?}");
|
||||
}
|
||||
|
||||
@@ -38,12 +38,7 @@ pub fn list_ports() -> Vec<String> {
|
||||
impl SerialClient {
|
||||
pub fn new(profile: &Profile, cb: Callbacks) -> SerialClient {
|
||||
SerialClient {
|
||||
inner: Arc::new(Inner {
|
||||
path: profile.host.trim().to_string(),
|
||||
cfg: profile.serial.clone().unwrap_or_default(),
|
||||
cb,
|
||||
port: Mutex::new(None),
|
||||
}),
|
||||
inner: Arc::new(Inner { path: profile.host.trim().to_string(), cfg: profile.serial.clone().unwrap_or_default(), cb, port: Mutex::new(None) }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,9 +287,15 @@ mod tests {
|
||||
let (etx, erx) = mpsc::channel();
|
||||
let (ctx, crx) = mpsc::channel();
|
||||
let cb = Callbacks {
|
||||
on_data: Arc::new(move |d| { let _ = dtx.send(d.to_vec()); }),
|
||||
on_error: Arc::new(move |e| { let _ = etx.send(e); }),
|
||||
on_close: Arc::new(move || { let _ = ctx.send(()); }),
|
||||
on_data: Arc::new(move |d| {
|
||||
let _ = dtx.send(d.to_vec());
|
||||
}),
|
||||
on_error: Arc::new(move |e| {
|
||||
let _ = etx.send(e);
|
||||
}),
|
||||
on_close: Arc::new(move || {
|
||||
let _ = ctx.send(());
|
||||
}),
|
||||
};
|
||||
let profile = Profile { host: pty.path.clone(), serial: Some(cfg), ..Profile::new(models::Protocol::Serial) };
|
||||
(SerialClient::new(&profile, cb), drx, erx, crx)
|
||||
|
||||
@@ -31,19 +31,18 @@ pub struct Progress {
|
||||
}
|
||||
|
||||
fn mode_string(m: &russh_sftp::protocol::FileAttributes) -> String {
|
||||
let kind = if m.is_dir() { 'd' } else if m.is_symlink() { 'L' } else { '-' };
|
||||
let kind = if m.is_dir() {
|
||||
'd'
|
||||
} else if m.is_symlink() {
|
||||
'L'
|
||||
} else {
|
||||
'-'
|
||||
};
|
||||
format!("{kind}{}", m.permissions())
|
||||
}
|
||||
|
||||
fn entry(name: String, path: String, m: &russh_sftp::protocol::FileAttributes) -> FileEntry {
|
||||
FileEntry {
|
||||
name,
|
||||
path,
|
||||
size: m.size.unwrap_or(0),
|
||||
is_dir: m.is_dir(),
|
||||
mode: mode_string(m),
|
||||
modified_at: m.mtime.map_or(0, |t| t as i64 * 1000),
|
||||
}
|
||||
FileEntry { name, path, size: m.size.unwrap_or(0), is_dir: m.is_dir(), mode: mode_string(m), modified_at: m.mtime.map_or(0, |t| t as i64 * 1000) }
|
||||
}
|
||||
|
||||
/// `path.Join` for remote (always '/') paths.
|
||||
@@ -60,10 +59,8 @@ pub fn join(dir: &str, name: &str) -> String {
|
||||
/// Directories first, then case-insensitive name order.
|
||||
pub fn list(s: &Arc<SftpSession>, path: &str) -> Result<Vec<FileEntry>> {
|
||||
let rd = rt().block_on(s.read_dir(path)).map_err(|e| format!("sftpclient: failed to list {path}: {e}"))?;
|
||||
let mut out: Vec<FileEntry> = rd
|
||||
.filter(|e| e.file_name() != "." && e.file_name() != "..")
|
||||
.map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata()))
|
||||
.collect();
|
||||
let mut out: Vec<FileEntry> =
|
||||
rd.filter(|e| e.file_name() != "." && e.file_name() != "..").map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata())).collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! port forwards, agent forwarding and SFTP. A port of internal/sshclient + sftpclient.
|
||||
|
||||
use super::{Callbacks, rt};
|
||||
use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig};
|
||||
use crate::Result;
|
||||
use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig};
|
||||
use base64::Engine;
|
||||
use russh::client::{self, Handle, Msg};
|
||||
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey};
|
||||
@@ -160,12 +160,9 @@ async fn connect_agent() -> Result<Agent> {
|
||||
|
||||
fn is_totp_prompt(q: &str) -> bool {
|
||||
let q = q.to_lowercase();
|
||||
[
|
||||
"verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator",
|
||||
"security code", "6-digit", "6 digit",
|
||||
]
|
||||
.iter()
|
||||
.any(|k| q.contains(k))
|
||||
["verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator", "security code", "6-digit", "6 digit"]
|
||||
.iter()
|
||||
.any(|k| q.contains(k))
|
||||
}
|
||||
|
||||
/// RFC 6238 code (SHA-1, 6 digits, 30 s) for a base32 secret, normalised as pquerna/otp does.
|
||||
@@ -448,7 +445,9 @@ impl SshClient {
|
||||
let host = host.to_string();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||
let addr = listener.local_addr()?;
|
||||
let ch = rt().block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0)).map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?;
|
||||
let ch = rt()
|
||||
.block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0))
|
||||
.map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?;
|
||||
let client = std::net::TcpStream::connect(addr)?;
|
||||
let (server, _) = listener.accept()?;
|
||||
server.set_nonblocking(true)?;
|
||||
@@ -488,7 +487,11 @@ impl SshClient {
|
||||
let methods = if sc.auth_methods.is_empty() { SshConfig::defaults().auth_methods } else { sc.auth_methods.clone() };
|
||||
let key = if methods.iter().any(|m| m == "publickey") && !sc.private_key_path.is_empty() {
|
||||
let (path, pp) = (sc.private_key_path.clone(), passphrase.clone());
|
||||
Some(blocking(move || load_private_key(&path, pp.as_ref())).await.map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?)
|
||||
Some(
|
||||
blocking(move || load_private_key(&path, pp.as_ref()))
|
||||
.await
|
||||
.map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -498,17 +501,16 @@ impl SshClient {
|
||||
self.handshake(tcp, &p.host, port, &check).await?
|
||||
} else {
|
||||
let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?;
|
||||
let ch = prev.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
|
||||
let ch = prev
|
||||
.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
|
||||
self.hops.lock().unwrap().extend(h);
|
||||
self.hops.lock().unwrap().push(prev);
|
||||
self.handshake(ch.into_stream(), &p.host, port, &check).await?
|
||||
};
|
||||
|
||||
authenticate(
|
||||
&mut handle,
|
||||
Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() },
|
||||
)
|
||||
.await?;
|
||||
authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }).await?;
|
||||
let handle = Arc::new(handle);
|
||||
if !shell {
|
||||
*self.handle.lock().unwrap() = Some(handle);
|
||||
@@ -565,7 +567,13 @@ impl SshClient {
|
||||
}
|
||||
|
||||
/// Authenticates through each jump host in turn; returns the last hop and the ones before it.
|
||||
async fn via_jumps(&self, sc: &SshConfig, check: &HostKeyCheck, passphrase: Option<SecretFn>, timeout: Duration) -> Result<(Arc<Handle<Handler>>, Vec<Arc<Handle<Handler>>>)> {
|
||||
async fn via_jumps(
|
||||
&self,
|
||||
sc: &SshConfig,
|
||||
check: &HostKeyCheck,
|
||||
passphrase: Option<SecretFn>,
|
||||
timeout: Duration,
|
||||
) -> Result<(Arc<Handle<Handler>>, Vec<Arc<Handle<Handler>>>)> {
|
||||
let mut chain: Vec<Arc<Handle<Handler>>> = vec![];
|
||||
for (i, j) in sc.jump_hosts.iter().enumerate() {
|
||||
let JumpHost { host, port, username, private_key_path, .. } = j;
|
||||
@@ -585,13 +593,23 @@ impl SshClient {
|
||||
self.handshake(tcp, host, jport, check).await?
|
||||
}
|
||||
Some(prev) => {
|
||||
let ch = prev.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?;
|
||||
let ch = prev
|
||||
.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?;
|
||||
self.handshake(ch.into_stream(), host, jport, check).await?
|
||||
}
|
||||
};
|
||||
authenticate(
|
||||
&mut h,
|
||||
Auth { user: username, methods: vec!["publickey".into()], agent: key.is_none() || sc.agent_auth, key, password: None, totp_secret: String::new() },
|
||||
Auth {
|
||||
user: username,
|
||||
methods: vec!["publickey".into()],
|
||||
agent: key.is_none() || sc.agent_auth,
|
||||
key,
|
||||
password: None,
|
||||
totp_secret: String::new(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("failed to establish SSH through jump host {i}: {e}"))?;
|
||||
@@ -678,9 +696,8 @@ impl SshClient {
|
||||
ForwardDirection::Local => {
|
||||
let bind = if rule.bind_host.is_empty() { "127.0.0.1".to_string() } else { rule.bind_host.clone() };
|
||||
let addr = format!("{bind}:{}", rule.bind_port);
|
||||
let listener = rt()
|
||||
.block_on(tokio::net::TcpListener::bind(addr.clone()))
|
||||
.map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?;
|
||||
let listener =
|
||||
rt().block_on(tokio::net::TcpListener::bind(addr.clone())).map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?;
|
||||
let (dhost, dport) = (rule.dest_host.clone(), rule.dest_port as u32);
|
||||
let t2 = tasks.clone();
|
||||
let task = rt().spawn(async move {
|
||||
@@ -755,9 +772,8 @@ impl SshClient {
|
||||
let h = self.handle()?;
|
||||
let ch = h.channel_open_session().await?;
|
||||
ch.request_subsystem(true, "sftp").await?;
|
||||
let s = Arc::new(
|
||||
russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?,
|
||||
);
|
||||
let s =
|
||||
Arc::new(russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?);
|
||||
*slot = Some(s.clone());
|
||||
Ok(s)
|
||||
})
|
||||
|
||||
@@ -367,8 +367,7 @@ mod tests {
|
||||
let (mut srv, _) = listener.accept().unwrap();
|
||||
srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
|
||||
srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99])
|
||||
.unwrap();
|
||||
srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99]).unwrap();
|
||||
let mut want = vec![IAC, WILL, OPT_TERM_TYPE, IAC, WILL, OPT_NAWS, IAC, SB, OPT_NAWS, 0, 80, 0, 24, IAC, SE];
|
||||
want.extend_from_slice(&[IAC, DO, OPT_ECHO, IAC, DO, OPT_SGA, IAC, WONT, 99]);
|
||||
assert_eq!(read_n(&mut srv, want.len()), want);
|
||||
|
||||
@@ -76,7 +76,7 @@ pub struct Framebuffer {
|
||||
impl Framebuffer {
|
||||
fn new(width: u16, height: u16) -> Framebuffer {
|
||||
let mut rgba = vec![0u8; width as usize * height as usize * 4];
|
||||
rgba.chunks_exact_mut(4).for_each(|p| p[3] = 255);
|
||||
rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255);
|
||||
Framebuffer { width, height, rgba, dirty: true }
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ impl Framebuffer {
|
||||
let (w, stride) = (w as usize, self.width as usize);
|
||||
for row in 0..h as usize {
|
||||
let dst = ((y as usize + row) * stride + x as usize) * 4;
|
||||
for (d, s) in self.rgba[dst..dst + w * 4].chunks_exact_mut(4).zip(px[row * w * bpp..].chunks_exact(bpp)) {
|
||||
for (d, s) in self.rgba[dst..dst + w * 4].as_chunks_mut::<4>().0.iter_mut().zip(px[row * w * bpp..].chunks_exact(bpp)) {
|
||||
d[..3].copy_from_slice(&s[..3]);
|
||||
d[3] = 255;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ impl Framebuffer {
|
||||
let stride = self.width as usize;
|
||||
for row in 0..h as usize {
|
||||
let dst = ((y as usize + row) * stride + x as usize) * 4;
|
||||
for d in self.rgba[dst..dst + w as usize * 4].chunks_exact_mut(4) {
|
||||
for d in self.rgba[dst..dst + w as usize * 4].as_chunks_mut::<4>().0 {
|
||||
d[..3].copy_from_slice(&rgb[..3]);
|
||||
d[3] = 255;
|
||||
}
|
||||
@@ -241,13 +241,13 @@ impl Client {
|
||||
st.connected = false;
|
||||
r
|
||||
};
|
||||
if let Err(e) = result {
|
||||
if !was_closing {
|
||||
let msg = e.to_string();
|
||||
(self.cfg.on_error)(msg.clone());
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(msg));
|
||||
}
|
||||
if let Err(e) = result
|
||||
&& !was_closing
|
||||
{
|
||||
let msg = e.to_string();
|
||||
(self.cfg.on_error)(msg.clone());
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(msg));
|
||||
}
|
||||
}
|
||||
if was_connected {
|
||||
@@ -334,7 +334,13 @@ impl Client {
|
||||
None => return Err(format!("not a VNC server (got {:?})", ver.trim_end()).into()),
|
||||
};
|
||||
// 3.889 is Apple's 3.8; anything between 3.3 and 3.7 is treated as 3.3, as RFB specifies.
|
||||
let minor = if minor >= 8 { 8 } else if minor == 7 { 7 } else { 3 };
|
||||
let minor = if minor >= 8 {
|
||||
8
|
||||
} else if minor == 7 {
|
||||
7
|
||||
} else {
|
||||
3
|
||||
};
|
||||
r.get_mut().send(format!("RFB 003.00{minor}\n").as_bytes())?;
|
||||
|
||||
let security = if minor == 3 {
|
||||
@@ -368,15 +374,13 @@ impl Client {
|
||||
}
|
||||
t => return Err(unsupported_security(&[t as u8]).into()),
|
||||
}
|
||||
if minor == 8 || security == 2 {
|
||||
if read_u32(r)? != 0 {
|
||||
let reason = if minor == 8 { read_string(r)? } else { String::new() };
|
||||
return Err(if reason.is_empty() {
|
||||
"VNC authentication failed (wrong password?)".into()
|
||||
} else {
|
||||
format!("VNC authentication failed: {reason}").into()
|
||||
});
|
||||
}
|
||||
if (minor == 8 || security == 2) && read_u32(r)? != 0 {
|
||||
let reason = if minor == 8 { read_string(r)? } else { String::new() };
|
||||
return Err(if reason.is_empty() {
|
||||
"VNC authentication failed (wrong password?)".into()
|
||||
} else {
|
||||
format!("VNC authentication failed: {reason}").into()
|
||||
});
|
||||
}
|
||||
|
||||
r.get_mut().send(&[self.cfg.shared as u8])?;
|
||||
@@ -414,11 +418,11 @@ pub fn vnc_auth_response(password: &str, challenge: &[u8; 16]) -> [u8; 16] {
|
||||
}
|
||||
let cipher = des::Des::new(&key.into());
|
||||
let mut out = *challenge;
|
||||
for chunk in out.chunks_exact_mut(8) {
|
||||
let mut block: [u8; 8] = chunk.try_into().unwrap();
|
||||
for chunk in out.as_chunks_mut::<8>().0 {
|
||||
let mut block: [u8; 8] = *chunk;
|
||||
let b = (&mut block).into();
|
||||
cipher.encrypt_block(b);
|
||||
chunk.copy_from_slice(&block);
|
||||
*chunk = block;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -502,11 +506,7 @@ struct Decoders {
|
||||
|
||||
impl Default for Decoders {
|
||||
fn default() -> Decoders {
|
||||
Decoders {
|
||||
zrle: Decompress::new(true),
|
||||
tight: std::array::from_fn(|_| Decompress::new(true)),
|
||||
buf: Vec::new(),
|
||||
}
|
||||
Decoders { zrle: Decompress::new(true), tight: std::array::from_fn(|_| Decompress::new(true)), buf: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,9 +726,7 @@ fn tight(r: &mut impl Read, fb: &mut Framebuffer, dec: &mut Decoders, x: u16, y:
|
||||
9 => {
|
||||
let len = compact_len(r)?;
|
||||
let jpeg = read_n(r, len)?;
|
||||
let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg)
|
||||
.map_err(|e| format!("Tight JPEG: {e}"))?
|
||||
.to_rgb8();
|
||||
let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).map_err(|e| format!("Tight JPEG: {e}"))?.to_rgb8();
|
||||
if img.width() as usize != wu || img.height() as usize != hu {
|
||||
return Err("Tight JPEG size doesn't match its rectangle".into());
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary
|
||||
//! Link are the same format.
|
||||
|
||||
use crate::Result;
|
||||
use crate::models::Profile;
|
||||
use crate::secrets::{derive_key, random_bytes};
|
||||
use crate::Result;
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -409,10 +409,7 @@ mod tests {
|
||||
fn hex_colours() {
|
||||
assert_eq!(
|
||||
format_text("\x04ff8800orange\x04 default"),
|
||||
vec![
|
||||
span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }),
|
||||
span(" default", Style::default())
|
||||
]
|
||||
vec![span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }), span(" default", Style::default())]
|
||||
);
|
||||
let s = format_text("\x0400ff00,0000FFx");
|
||||
assert_eq!(s[0].style, Style { fg_rgb: Some([0, 255, 0]), bg_rgb: Some([0, 0, 255]), ..Style::default() });
|
||||
@@ -444,10 +441,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn event_lines() {
|
||||
let message = format_event(
|
||||
&Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() },
|
||||
"12:34:56",
|
||||
);
|
||||
let message =
|
||||
format_event(&Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() }, "12:34:56");
|
||||
assert_eq!(message[0], span("[12:34:56]", Style { dim: true, ..Style::fg(GREY) }));
|
||||
assert!(message.iter().any(|s| s.text == "world" && s.style.bold));
|
||||
let nick = message.iter().find(|s| s.text == "alice").unwrap();
|
||||
@@ -458,10 +453,7 @@ mod tests {
|
||||
let action = format_event(&Event { kind: "action".into(), from: "bob".into(), text: "waves".into(), ..Default::default() }, "01:02:03");
|
||||
assert!(action.iter().any(|s| s.text.ends_with("waves") && s.style.italic && s.style.fg == Some(MAGENTA)));
|
||||
|
||||
let kick = format_event(
|
||||
&Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() },
|
||||
"t",
|
||||
);
|
||||
let kick = format_event(&Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() }, "t");
|
||||
assert_eq!(kick.iter().map(|s| s.text.as_str()).collect::<String>(), "[t] <-- eve was kicked by op (spam)");
|
||||
let system = format_event(&Event { kind: "system".into(), text: "hi".into(), ..Default::default() }, "t");
|
||||
assert_eq!(system.last().unwrap(), &span("hi", Style::fg(CYAN)));
|
||||
|
||||
@@ -5,25 +5,11 @@ use crate::Result;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const PRIVATE_KEY_MARKERS: [&str; 6] = [
|
||||
"BEGIN OPENSSH PRIVATE KEY",
|
||||
"BEGIN RSA PRIVATE KEY",
|
||||
"BEGIN DSA PRIVATE KEY",
|
||||
"BEGIN EC PRIVATE KEY",
|
||||
"BEGIN ED25519 PRIVATE KEY",
|
||||
"BEGIN PRIVATE KEY",
|
||||
];
|
||||
const PRIVATE_KEY_MARKERS: [&str; 6] =
|
||||
["BEGIN OPENSSH PRIVATE KEY", "BEGIN RSA PRIVATE KEY", "BEGIN DSA PRIVATE KEY", "BEGIN EC PRIVATE KEY", "BEGIN ED25519 PRIVATE KEY", "BEGIN PRIVATE KEY"];
|
||||
|
||||
const IGNORED: [&str; 8] = [
|
||||
"authorized_keys",
|
||||
"authorized_keys2",
|
||||
"config",
|
||||
"known_hosts",
|
||||
"known_hosts.old",
|
||||
"ssh_config",
|
||||
"ssh_known_hosts",
|
||||
"ssh_known_hosts2",
|
||||
];
|
||||
const IGNORED: [&str; 8] =
|
||||
["authorized_keys", "authorized_keys2", "config", "known_hosts", "known_hosts.old", "ssh_config", "ssh_known_hosts", "ssh_known_hosts2"];
|
||||
|
||||
/// Whether the first 512 bytes look like a private key. Unreadable or empty paths are `false`
|
||||
/// (this is also Go's `FileIsPrivateKey`).
|
||||
@@ -33,8 +19,7 @@ pub fn is_likely_private_key(path: &Path) -> bool {
|
||||
if f.take(512).read_to_end(&mut head).is_err() || head.is_empty() {
|
||||
return false;
|
||||
}
|
||||
head.starts_with(b"openssh-key-v1")
|
||||
|| PRIVATE_KEY_MARKERS.iter().any(|m| head.windows(m.len()).any(|w| w == m.as_bytes()))
|
||||
head.starts_with(b"openssh-key-v1") || PRIVATE_KEY_MARKERS.iter().any(|m| head.windows(m.len()).any(|w| w == m.as_bytes()))
|
||||
}
|
||||
|
||||
/// Candidate private keys in `~/.ssh`, sorted. A missing home or `.ssh` gives an empty list.
|
||||
|
||||
@@ -52,8 +52,7 @@ pub enum Protocol {
|
||||
}
|
||||
|
||||
impl Protocol {
|
||||
pub const SAVED: [Protocol; 7] =
|
||||
[Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial];
|
||||
pub const SAVED: [Protocol; 7] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Protocol::Ssh => "ssh",
|
||||
|
||||
@@ -340,10 +340,7 @@ impl Store {
|
||||
}
|
||||
}
|
||||
let id = filename.trim_end_matches(".cast").to_string();
|
||||
let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0)
|
||||
.ok_or("recording: invalid timestamp")?
|
||||
.with_timezone(&chrono::Local)
|
||||
.fixed_offset();
|
||||
let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0).ok_or("recording: invalid timestamp")?.with_timezone(&chrono::Local).fixed_offset();
|
||||
Ok(RecordingMeta {
|
||||
session_id: id.clone(), // recording ID matches session ID by default
|
||||
id,
|
||||
|
||||
@@ -43,9 +43,7 @@ pub fn open(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||
if data.len() < 12 {
|
||||
return Err("ciphertext too short".into());
|
||||
}
|
||||
Aes256Gcm::new(key.into())
|
||||
.decrypt(Nonce::from_slice(&data[..12]), &data[12..])
|
||||
.map_err(|_| "decryption failed".into())
|
||||
Aes256Gcm::new(key.into()).decrypt(Nonce::from_slice(&data[..12]), &data[12..]).map_err(|_| "decryption failed".into())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -93,10 +91,7 @@ impl Vault {
|
||||
return Err("secrets: vault already initialised".into());
|
||||
}
|
||||
let salt = random_bytes::<32>();
|
||||
let u = Unlocked {
|
||||
key: derive_key(password, &salt)?,
|
||||
vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() },
|
||||
};
|
||||
let u = Unlocked { key: derive_key(password, &salt)?, vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() } };
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
self.save(&u)?;
|
||||
*self.state.lock().unwrap() = Some(u);
|
||||
@@ -185,11 +180,7 @@ fn off_runtime<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
|
||||
fn entry(service: &str, key: &str) -> keyring::Result<keyring::Entry> {
|
||||
// go-keyring's Windows target is "service:user"; keyring's default differs.
|
||||
if cfg!(windows) {
|
||||
keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key)
|
||||
} else {
|
||||
keyring::Entry::new(service, key)
|
||||
}
|
||||
if cfg!(windows) { keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key) } else { keyring::Entry::new(service, key) }
|
||||
}
|
||||
|
||||
/// The OS keychain. Every call can fail (no secret service, locked keyring); callers fall back
|
||||
|
||||
@@ -229,7 +229,12 @@ impl Service {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.store.add_audit("export", "", "", &format!("Exported {} profile(s) {}", ps.len(), if include_secrets { "with secrets" } else { "without secrets" }));
|
||||
self.store.add_audit(
|
||||
"export",
|
||||
"",
|
||||
"",
|
||||
&format!("Exported {} profile(s) {}", ps.len(), if include_secrets { "with secrets" } else { "without secrets" }),
|
||||
);
|
||||
Ok(serde_json::to_string_pretty(&ps)?)
|
||||
}
|
||||
|
||||
@@ -238,7 +243,11 @@ impl Service {
|
||||
return Err(format!("import data is too large (max {MAX_IMPORT_JSON_BYTES} bytes)").into());
|
||||
}
|
||||
let ps: Vec<Option<Profile>> = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
let ps = ps.into_iter().enumerate().map(|(i, p)| p.ok_or_else(|| format!("profile import entry {i} is null"))).collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
let ps = ps
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| p.ok_or_else(|| format!("profile import entry {i} is null")))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
self.import(ps, strategy)
|
||||
}
|
||||
|
||||
|
||||
+71
-17
@@ -2,14 +2,14 @@
|
||||
//! internal/sessions — same lifecycle, reconnect backoff, login scripts, recording hooks and
|
||||
//! credential resolution — reporting to the UI through `Event`s.
|
||||
|
||||
use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet};
|
||||
use crate::Result;
|
||||
use crate::clients::local::LocalShell;
|
||||
use crate::clients::ssh::{HostKeyCheck, HostKeyInfo, SecretFn, SshClient};
|
||||
use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet};
|
||||
use crate::models::*;
|
||||
use crate::recording::{self, Recorder};
|
||||
use crate::secrets::Secrets;
|
||||
use crate::storage::Store;
|
||||
use crate::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -214,7 +214,9 @@ impl Manager {
|
||||
let id2 = id.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = me.connect_protocol(&id2, epoch, &profile, prompt);
|
||||
me.finish_connect(&id2, epoch, r, |me| me.store.add_audit("connect", &profile.id, &id2, &format!("Connected via {} to {}", profile.protocol.id(), profile.host)));
|
||||
me.finish_connect(&id2, epoch, r, |me| {
|
||||
me.store.add_audit("connect", &profile.id, &id2, &format!("Connected via {} to {}", profile.protocol.id(), profile.host))
|
||||
});
|
||||
});
|
||||
id
|
||||
}
|
||||
@@ -266,9 +268,8 @@ impl Manager {
|
||||
Protocol::Raw => {
|
||||
let cb = self.terminal_callbacks(id, epoch);
|
||||
let (me, sid) = (self.clone(), id.to_string());
|
||||
let on_data: raw::RawDataFn = Arc::new(move |d, dir, ts| {
|
||||
me.emit(Event::RawData { session: sid.clone(), data: d.to_vec(), direction: dir.into(), timestamp: ts })
|
||||
});
|
||||
let on_data: raw::RawDataFn =
|
||||
Arc::new(move |d, dir, ts| me.emit(Event::RawData { session: sid.clone(), data: d.to_vec(), direction: dir.into(), timestamp: ts }));
|
||||
let c = Arc::new(raw::RawClient::new(p, on_data, cb.on_error, cb.on_close));
|
||||
c.connect()?;
|
||||
Ok(Client::Raw(c))
|
||||
@@ -465,7 +466,20 @@ impl Manager {
|
||||
|
||||
/// An SSH connection used only to tunnel another protocol (IRC, VNC).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tunnel(self: &Arc<Self>, id: &str, profile_id: &str, host: &str, port: i64, user: &str, method: &str, key: &str, agent: bool, mode: SecretMode, kind: &str, prompt: Prompt) -> Result<Arc<SshClient>> {
|
||||
fn tunnel(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
profile_id: &str,
|
||||
host: &str,
|
||||
port: i64,
|
||||
user: &str,
|
||||
method: &str,
|
||||
key: &str,
|
||||
agent: bool,
|
||||
mode: SecretMode,
|
||||
kind: &str,
|
||||
prompt: Prompt,
|
||||
) -> Result<Arc<SshClient>> {
|
||||
let mut tp = Profile::new(Protocol::Ssh);
|
||||
tp.host = host.into();
|
||||
tp.port = if port == 0 { 22 } else { port };
|
||||
@@ -493,10 +507,7 @@ impl Manager {
|
||||
Err(e) => {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
let auth = ["token validation failed", "authentication failed", "http 401", "http 403"].iter().any(|k| msg.contains(k));
|
||||
let retry = auth
|
||||
.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok())
|
||||
.flatten()
|
||||
.filter(|t| !t.is_empty());
|
||||
let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty());
|
||||
let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) };
|
||||
if gh.secret_mode == SecretMode::Saved {
|
||||
self.secrets.set(&format!("profile:{}:github-token", p.id), &t);
|
||||
@@ -562,7 +573,19 @@ impl Manager {
|
||||
rooms = crate::storage::dedup_rooms(&[&rooms, &last]);
|
||||
}
|
||||
let tunnel = if ic.ssh_tunnel {
|
||||
Some(self.tunnel(id, &p.id, &ic.ssh_host, ic.ssh_port, &ic.ssh_username, &ic.ssh_auth_method, &ic.ssh_private_key, ic.ssh_agent_auth, ic.ssh_secret_mode, "irc", prompt)?)
|
||||
Some(self.tunnel(
|
||||
id,
|
||||
&p.id,
|
||||
&ic.ssh_host,
|
||||
ic.ssh_port,
|
||||
&ic.ssh_username,
|
||||
&ic.ssh_auth_method,
|
||||
&ic.ssh_private_key,
|
||||
ic.ssh_agent_auth,
|
||||
ic.ssh_secret_mode,
|
||||
"irc",
|
||||
prompt,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -629,14 +652,35 @@ impl Manager {
|
||||
let host = if vc.vnc_host.is_empty() { p.host.clone() } else { vc.vnc_host.clone() };
|
||||
let port = if vc.vnc_port == 0 { 5900 } else { vc.vnc_port } as u16;
|
||||
let tunnel = if vc.ssh_tunnel {
|
||||
Some(self.tunnel(id, &p.id, &vc.ssh_host, vc.ssh_port, &vc.ssh_username, &vc.ssh_auth_method, &vc.ssh_private_key, vc.ssh_agent_auth, vc.ssh_secret_mode, "vnc", prompt.clone())?)
|
||||
Some(self.tunnel(
|
||||
id,
|
||||
&p.id,
|
||||
&vc.ssh_host,
|
||||
vc.ssh_port,
|
||||
&vc.ssh_username,
|
||||
&vc.ssh_auth_method,
|
||||
&vc.ssh_private_key,
|
||||
vc.ssh_agent_auth,
|
||||
vc.ssh_secret_mode,
|
||||
"vnc",
|
||||
prompt.clone(),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let password = self.secret_fn(&p.id, "vnc-password", vc.secret_mode, prompt);
|
||||
let cb = self.terminal_callbacks(id, epoch);
|
||||
let (me, sid) = (self.clone(), id.to_string());
|
||||
let c = connect_vnc_client(&host, port, &vc, p.connect_timeout, password, tunnel.clone(), cb, Arc::new(move || me.emit(Event::VncFrame { session: sid.clone() })));
|
||||
let c = connect_vnc_client(
|
||||
&host,
|
||||
port,
|
||||
&vc,
|
||||
p.connect_timeout,
|
||||
password,
|
||||
tunnel.clone(),
|
||||
cb,
|
||||
Arc::new(move || me.emit(Event::VncFrame { session: sid.clone() })),
|
||||
);
|
||||
match c {
|
||||
Ok(c) => {
|
||||
self.emit(Event::VncReady { session: id.into() });
|
||||
@@ -811,7 +855,12 @@ impl Manager {
|
||||
return;
|
||||
};
|
||||
let delay = (1u64 << (attempt - 1).min(5)).min(30);
|
||||
self.emit(Event::State { session: id.into(), state: SessionState::Reconnecting, error: String::new(), retry: Some(Retry { attempt, max, delay_secs: delay }) });
|
||||
self.emit(Event::State {
|
||||
session: id.into(),
|
||||
state: SessionState::Reconnecting,
|
||||
error: String::new(),
|
||||
retry: Some(Retry { attempt, max, delay_secs: delay }),
|
||||
});
|
||||
let (me, id) = (self.clone(), id.to_string());
|
||||
std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(delay);
|
||||
@@ -903,7 +952,10 @@ impl Manager {
|
||||
"wait" => {
|
||||
let timeout = Duration::from_secs(if step.timeout <= 0 { 10 } else { step.timeout as u64 });
|
||||
if !wait_for_output(&login, &step.value, timeout) {
|
||||
me.emit(Event::Debug { session: id.clone(), message: format!("login script step {} timed out waiting for {:?}", i + 1, step.value) });
|
||||
me.emit(Event::Debug {
|
||||
session: id.clone(),
|
||||
message: format!("login script step {} timed out waiting for {:?}", i + 1, step.value),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1105,7 +1157,9 @@ impl Manager {
|
||||
if exists {
|
||||
return err(format!("sessions: session {id:?} is already being recorded"));
|
||||
}
|
||||
let r = Arc::new(Recorder::new(self.recordings.dir(), id, &title, cols as i64, rows as i64).map_err(|e| format!("sessions: failed to start recording: {e}"))?);
|
||||
let r = Arc::new(
|
||||
Recorder::new(self.recordings.dir(), id, &title, cols as i64, rows as i64).map_err(|e| format!("sessions: failed to start recording: {e}"))?,
|
||||
);
|
||||
self.with(id, |s| s.recorder = Some(r))?;
|
||||
self.emit(Event::RecordingStarted { session: id.into() });
|
||||
Ok(())
|
||||
|
||||
@@ -29,11 +29,7 @@ pub fn parse_file(path: &Path) -> Result<Vec<HostBlock>> {
|
||||
return Err(format!("sshconfig: {p} is a directory").into());
|
||||
}
|
||||
if meta.len() > MAX_CONFIG_FILE_BYTES {
|
||||
return Err(format!(
|
||||
"sshconfig: config file is too large: {} bytes exceeds {MAX_CONFIG_FILE_BYTES} bytes",
|
||||
meta.len()
|
||||
)
|
||||
.into());
|
||||
return Err(format!("sshconfig: config file is too large: {} bytes exceeds {MAX_CONFIG_FILE_BYTES} bytes", meta.len()).into());
|
||||
}
|
||||
let raw = std::fs::read(path).map_err(|e| format!("sshconfig: failed to open {p}: {e}"))?;
|
||||
parse(&String::from_utf8_lossy(&raw))
|
||||
@@ -138,8 +134,7 @@ fn parse_proxy_jump(value: &str) -> Option<JumpHost> {
|
||||
if value.is_empty() || value == "none" {
|
||||
return None;
|
||||
}
|
||||
let mut jh =
|
||||
JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() };
|
||||
let mut jh = JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() };
|
||||
if let Some((user, rest)) = value.split_once('@') {
|
||||
jh.username = user.into();
|
||||
value = rest;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! SQLite persistence: profiles, known hosts, preferences, audit log, snippets and IRC channel
|
||||
//! state. The schema and migration files are the Go app's, so existing databases open unchanged.
|
||||
|
||||
use crate::models::*;
|
||||
use crate::Result;
|
||||
use crate::models::*;
|
||||
use chrono::{DateTime, SecondsFormat};
|
||||
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||
use std::path::Path;
|
||||
@@ -228,10 +228,7 @@ impl Store {
|
||||
}
|
||||
|
||||
pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> {
|
||||
let n = self.db.lock().unwrap().execute(
|
||||
"UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?",
|
||||
params![fav as i64, fmt_time(&now()), id],
|
||||
)?;
|
||||
let n = self.db.lock().unwrap().execute("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", params![fav as i64, fmt_time(&now()), id])?;
|
||||
if n == 0 {
|
||||
return Err(format!("storage: profile {id:?} not found").into());
|
||||
}
|
||||
@@ -240,10 +237,7 @@ impl Store {
|
||||
|
||||
pub fn update_last_connected(&self, id: &str) -> Result<()> {
|
||||
let now = fmt_time(&now());
|
||||
self.db
|
||||
.lock()
|
||||
.unwrap()
|
||||
.execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
|
||||
self.db.lock().unwrap().execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -337,9 +331,7 @@ impl Store {
|
||||
|
||||
pub fn list_audit(&self, limit: i64) -> Result<Vec<AuditEntry>> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut st = db.prepare(
|
||||
"SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?",
|
||||
)?;
|
||||
let mut st = db.prepare("SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?")?;
|
||||
let rows = st
|
||||
.query_map([limit], |r| {
|
||||
Ok(AuditEntry {
|
||||
@@ -357,8 +349,7 @@ impl Store {
|
||||
|
||||
pub fn irc_last_rooms(&self, profile_id: &str) -> Result<Vec<String>> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut st =
|
||||
db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
|
||||
let mut st = db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?;
|
||||
let rows = st.query_map([profile_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -384,14 +375,8 @@ impl Store {
|
||||
}
|
||||
|
||||
pub fn preferences(&self) -> AppPreferences {
|
||||
let v: Option<String> = self
|
||||
.db
|
||||
.lock()
|
||||
.unwrap()
|
||||
.query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0))
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten();
|
||||
let v: Option<String> =
|
||||
self.db.lock().unwrap().query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0)).optional().ok().flatten();
|
||||
let mut p = v.and_then(|v| serde_json::from_str::<AppPreferences>(&v).ok()).unwrap_or_default();
|
||||
p.apply_defaults();
|
||||
p
|
||||
@@ -407,9 +392,7 @@ impl Store {
|
||||
|
||||
pub fn list_snippets(&self) -> Result<Vec<Snippet>> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut st = db.prepare(
|
||||
"SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC",
|
||||
)?;
|
||||
let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?;
|
||||
let rows = st.query_map([], scan_snippet)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
@@ -16,11 +16,7 @@ pub fn local_shell_env(env: Vec<String>) -> Vec<String> {
|
||||
/// Variables to request for an SSH session. A locale set by the profile is kept as is; otherwise
|
||||
/// a UTF-8 `LC_CTYPE` is added. Keys are trimmed and empty keys dropped. Iteration order is sorted.
|
||||
pub fn ssh_env_vars(profile_env: &BTreeMap<String, String>) -> BTreeMap<String, String> {
|
||||
let mut out: BTreeMap<String, String> = profile_env
|
||||
.iter()
|
||||
.map(|(k, v)| (k.trim().to_string(), v.clone()))
|
||||
.filter(|(k, _)| !k.is_empty())
|
||||
.collect();
|
||||
let mut out: BTreeMap<String, String> = profile_env.iter().map(|(k, v)| (k.trim().to_string(), v.clone())).filter(|(k, _)| !k.is_empty()).collect();
|
||||
if ![LC_ALL, LC_CTYPE, LANG].iter().any(|k| out.contains_key(*k)) {
|
||||
out.insert(LC_CTYPE.into(), default_utf8_locale(std::env::consts::OS).into());
|
||||
}
|
||||
|
||||
@@ -70,17 +70,8 @@ pub fn expand_update_url(template: &str) -> String {
|
||||
|
||||
/// GET with the updater headers; the body is read (bounded) only on 200.
|
||||
fn fetch(url: &str, accept: &str) -> Result<(u16, Vec<u8>)> {
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(REQUEST_TIMEOUT))
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.into();
|
||||
let resp = agent
|
||||
.get(url)
|
||||
.header("Accept", accept)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.call()
|
||||
.map_err(|e| format!("updater: request failed: {e}"))?;
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(REQUEST_TIMEOUT)).http_status_as_error(false).build().into();
|
||||
let resp = agent.get(url).header("Accept", accept).header("User-Agent", USER_AGENT).call().map_err(|e| format!("updater: request failed: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status != 200 {
|
||||
return Ok((status, vec![]));
|
||||
@@ -130,8 +121,7 @@ pub fn check_github(current_version: &str) -> Result<UpdateInfo> {
|
||||
if status != 200 {
|
||||
return Err(format!("updater: GitHub API returned {status}").into());
|
||||
}
|
||||
let release: Release =
|
||||
serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?;
|
||||
let release: Release = serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?;
|
||||
let latest = normalise_version(&release.tag_name);
|
||||
if release.draft || release.prerelease {
|
||||
return Ok(UpdateInfo { current_version: current, latest_version: latest, ..Default::default() });
|
||||
@@ -189,10 +179,7 @@ fn is_newer(latest: &str, current: &str) -> bool {
|
||||
/// Go's `fmt.Sscanf(s, "%d")`: optional sign then digits; anything else is 0.
|
||||
fn leading_int(s: Option<&&str>) -> i64 {
|
||||
let s = s.map_or("", |s| s.trim_start());
|
||||
let end = s
|
||||
.char_indices()
|
||||
.find(|&(i, c)| !(c.is_ascii_digit() || (i == 0 && (c == '+' || c == '-'))))
|
||||
.map_or(s.len(), |(i, _)| i);
|
||||
let end = s.char_indices().find(|&(i, c)| !(c.is_ascii_digit() || (i == 0 && (c == '+' || c == '-')))).map_or(s.len(), |(i, _)| i);
|
||||
s[..end].parse().unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -228,10 +215,7 @@ mod tests {
|
||||
}
|
||||
req.extend(&buf[..n]);
|
||||
}
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let head = format!("HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
|
||||
let _ = s.write_all(head.as_bytes());
|
||||
let _ = s.write_all(&body);
|
||||
});
|
||||
|
||||
@@ -380,8 +380,7 @@ pub fn validate_serial_config(cfg: &SerialConfig) -> Result<(), ValidationErrors
|
||||
fn is_valid_nick(nick: &str) -> bool {
|
||||
let b = nick.as_bytes();
|
||||
let Some((&first, rest)) = b.split_first() else { return false };
|
||||
((b'A'..=b'}').contains(&first) || first == b'?')
|
||||
&& rest.iter().all(|c| (b'A'..=b'}').contains(c) || c.is_ascii_digit() || *c == b'-')
|
||||
((b'A'..=b'}').contains(&first) || first == b'?') && rest.iter().all(|c| (b'A'..=b'}').contains(c) || c.is_ascii_digit() || *c == b'-')
|
||||
}
|
||||
|
||||
/// girc's IsValidChannel.
|
||||
@@ -526,14 +525,8 @@ mod tests {
|
||||
assert!(validate_vnc_config("", &named).is_ok());
|
||||
assert!(validate_vnc_config("desktop.local", &base).is_ok());
|
||||
assert!(validate_vnc_config("", &base).is_err());
|
||||
let tunnel = VncConfig {
|
||||
ssh_tunnel: true,
|
||||
ssh_host: "jump.local".into(),
|
||||
ssh_port: 22,
|
||||
ssh_username: "admin".into(),
|
||||
ssh_auth_method: "token".into(),
|
||||
..base
|
||||
};
|
||||
let tunnel =
|
||||
VncConfig { ssh_tunnel: true, ssh_host: "jump.local".into(), ssh_port: 22, ssh_username: "admin".into(), ssh_auth_method: "token".into(), ..base };
|
||||
assert!(validate_vnc_config("desktop.local", &tunnel).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,8 @@ fn free_port() -> u16 {
|
||||
fn start(name: &str) -> Option<Server> {
|
||||
let sshd = which("sshd")?;
|
||||
let keygen = which("ssh-keygen")?;
|
||||
let sftp_server = ["/usr/lib/openssh/sftp-server", "/usr/libexec/openssh/sftp-server", "/usr/libexec/sftp-server"]
|
||||
.iter()
|
||||
.find(|p| Path::new(p).exists())?;
|
||||
let sftp_server =
|
||||
["/usr/lib/openssh/sftp-server", "/usr/libexec/openssh/sftp-server", "/usr/libexec/sftp-server"].iter().find(|p| Path::new(p).exists())?;
|
||||
let dir = std::env::temp_dir().join(format!("nexus-sshd-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
@@ -95,10 +94,13 @@ fn callbacks() -> (Callbacks, Seen) {
|
||||
fn accept_all() -> (HostKeyCheck, Arc<Mutex<Vec<String>>>) {
|
||||
let fps: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let f2 = fps.clone();
|
||||
(Arc::new(move |i| {
|
||||
f2.lock().unwrap().push(format!("{} {}", i.key_type, i.fingerprint));
|
||||
Ok(())
|
||||
}), fps)
|
||||
(
|
||||
Arc::new(move |i| {
|
||||
f2.lock().unwrap().push(format!("{} {}", i.key_type, i.fingerprint));
|
||||
Ok(())
|
||||
}),
|
||||
fps,
|
||||
)
|
||||
}
|
||||
|
||||
fn wait_for(seen: &Seen, needle: &str) -> bool {
|
||||
@@ -151,7 +153,9 @@ fn echo_through(port: u16) -> String {
|
||||
|
||||
#[test]
|
||||
fn shell_exec_forwards_sftp() {
|
||||
let Some(s) = start("main") else { return eprintln!("sshd not available; skipping") };
|
||||
let Some(s) = start("main") else {
|
||||
return eprintln!("sshd not available; skipping");
|
||||
};
|
||||
let (cb, seen) = callbacks();
|
||||
let (check, fps) = accept_all();
|
||||
let c = SshClient::new(&profile(&s), cb);
|
||||
@@ -166,11 +170,28 @@ fn shell_exec_forwards_sftp() {
|
||||
|
||||
let echo = echo_server();
|
||||
let lport = free_port();
|
||||
let rule = PortForward { id: "l".into(), direction: ForwardDirection::Local, bind_port: lport as i64, dest_host: "127.0.0.1".into(), dest_port: echo as i64, enabled: true, ..Default::default() };
|
||||
let rule = PortForward {
|
||||
id: "l".into(),
|
||||
direction: ForwardDirection::Local,
|
||||
bind_port: lport as i64,
|
||||
dest_host: "127.0.0.1".into(),
|
||||
dest_port: echo as i64,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
c.start_forward(rule).unwrap();
|
||||
assert_eq!(echo_through(lport), "ping");
|
||||
let rport = free_port();
|
||||
let rule = PortForward { id: "r".into(), direction: ForwardDirection::Remote, bind_host: "127.0.0.1".into(), bind_port: rport as i64, dest_host: "127.0.0.1".into(), dest_port: echo as i64, enabled: true, ..Default::default() };
|
||||
let rule = PortForward {
|
||||
id: "r".into(),
|
||||
direction: ForwardDirection::Remote,
|
||||
bind_host: "127.0.0.1".into(),
|
||||
bind_port: rport as i64,
|
||||
dest_host: "127.0.0.1".into(),
|
||||
dest_port: echo as i64,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
c.start_forward(rule).unwrap();
|
||||
assert_eq!(echo_through(rport), "ping");
|
||||
assert_eq!(c.active_forwards().len(), 2);
|
||||
@@ -226,7 +247,8 @@ fn jump_host_chain() {
|
||||
let Some(s) = start("jump") else { return };
|
||||
let mut p = profile(&s);
|
||||
let key = p.ssh.as_ref().unwrap().private_key_path.clone();
|
||||
p.ssh.as_mut().unwrap().jump_hosts = vec![JumpHost { host: "127.0.0.1".into(), port: s.port as i64, username: p.username.clone(), private_key_path: key, ..Default::default() }];
|
||||
p.ssh.as_mut().unwrap().jump_hosts =
|
||||
vec![JumpHost { host: "127.0.0.1".into(), port: s.port as i64, username: p.username.clone(), private_key_path: key, ..Default::default() }];
|
||||
let (check, fps) = accept_all();
|
||||
let c = SshClient::new(&p, Callbacks::noop());
|
||||
c.connect(check, None, None).unwrap();
|
||||
@@ -238,7 +260,9 @@ fn jump_host_chain() {
|
||||
#[test]
|
||||
fn agent_auth_and_forwarding() {
|
||||
let Some(mut s) = start("agent") else { return };
|
||||
let (Some(agent), Some(add)) = (which("ssh-agent"), which("ssh-add")) else { return };
|
||||
let (Some(agent), Some(add)) = (which("ssh-agent"), which("ssh-add")) else {
|
||||
return;
|
||||
};
|
||||
let sock = s.dir.join("agent.sock");
|
||||
s.agent = Some(Command::new(agent).arg("-D").arg("-a").arg(&sock).spawn().unwrap());
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
max_width = 160
|
||||
use_small_heuristics = "Max"
|
||||
Reference in New Issue
Block a user