diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b841210..1f5dbd0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -339,6 +339,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -869,6 +875,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.4.4" @@ -2255,6 +2267,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -2807,14 +2825,17 @@ dependencies = [ "rusqlite", "russh", "russh-sftp", + "rustls", "serde", "serde_json", "serialport", "sha1 0.11.0", "sha2 0.11.0", + "tiny_http", "tokio", "ureq", "uuid", + "webpki-roots", ] [[package]] @@ -4910,6 +4931,18 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tokio" version = "1.53.2" diff --git a/rust/app/src/terminal.rs b/rust/app/src/terminal.rs new file mode 100644 index 0000000..502c4f7 --- /dev/null +++ b/rust/app/src/terminal.rs @@ -0,0 +1,892 @@ +//! TermView: an alacritty_terminal grid drawn with egui's painter. Bytes from a session go +//! straight into the VT parser; keystrokes, paste, mouse reports and terminal replies come back +//! out through `TermResponse` for the app to send to the session (and broadcast targets). + +use crate::terminal_themes::TermTheme; +use alacritty_terminal::event::{Event as TermEvent, EventListener}; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::index::{Column, Line, Point, Side}; +use alacritty_terminal::selection::{Selection, SelectionType}; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::test::TermSize; +use alacritty_terminal::term::{Config, Term, TermMode}; +use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler}; +use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Default)] +struct Listener(Arc>>); + +impl EventListener for Listener { + fn send_event(&self, e: TermEvent) { + if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) { + self.0.lock().unwrap().push(e); + } + } +} + +/// What the context menu asked for; the app carries these out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuAction { + Copy, + Paste, + SelectAll, + Find, + SendSnippet, + PortForwards, + SplitRight, + SplitDown, + Export, + Clear, + Reset, +} + +#[derive(Default)] +pub struct TermResponse { + /// User input (keys, paste, mouse reports): goes to the session and broadcast targets. + pub input: Vec, + /// Terminal replies (DA, DSR, …): go to this session only. + pub reply: Vec, + pub resized: Option<(u16, u16)>, + pub menu: Option, + pub title: Option, + pub bell: bool, + pub focused: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum CursorStyle { + #[default] + Block, + Underline, + Bar, +} + +impl CursorStyle { + pub fn from_id(s: &str) -> Self { + match s { + "underline" => Self::Underline, + "bar" => Self::Bar, + _ => Self::Block, + } + } +} + +#[derive(Default)] +pub struct Find { + pub query: String, + pub case_sensitive: bool, + pub regex: bool, + /// (grid line, first column, last column inclusive) + pub matches: Vec<(i32, usize, usize)>, + pub current: usize, + pub error: Option, + computed_for: Option<(String, bool, bool, u64)>, +} + +pub struct TermView { + term: Term, + parser: Processor, + events: Listener, + pub font_size: f32, + pub cursor: CursorStyle, + cols: u16, + rows: u16, + drag: Option<(Point, SelectionType)>, + last_click: (f64, u32, Pos2), + wheel_acc: f32, + mouse_down: Option, + /// Bumped on every feed, so find results know when to recompute. + generation: u64, + pub find: Option, + focus_requested: bool, +} + +const URL_RE: &str = r#"(?:https?|ftp)://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]]"#; + +fn rgb(c: [u8; 4]) -> Color32 { + Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]) +} + +impl TermView { + pub fn new(scrollback: usize, font_size: f32, cursor: CursorStyle) -> TermView { + let events = Listener::default(); + let config = Config { scrolling_history: scrollback.min(100_000), ..Config::default() }; + TermView { + term: Term::new(config, &TermSize::new(80, 24), events.clone()), + parser: Processor::new(), + events, + font_size: font_size.clamp(6.0, 48.0), + cursor, + cols: 80, + rows: 24, + drag: None, + last_click: (0.0, 0, Pos2::ZERO), + wheel_acc: 0.0, + mouse_down: None, + generation: 0, + find: None, + focus_requested: true, + } + } + + pub fn feed(&mut self, bytes: &[u8]) { + self.parser.advance(&mut self.term, bytes); + self.generation += 1; + } + + pub fn size(&self) -> (u16, u16) { + (self.cols, self.rows) + } + + pub fn request_focus(&mut self) { + 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 { + self.term.selection_to_string().filter(|s| !s.is_empty()) + } + + pub fn select_all(&mut self) { + let top = self.term.grid().topmost_line(); + let bottom = self.term.grid().bottommost_line(); + let mut s = Selection::new(SelectionType::Simple, Point::new(top, Column(0)), Side::Left); + s.update(Point::new(bottom, Column(self.cols.saturating_sub(1) as usize)), Side::Right); + self.term.selection = Some(s); + } + + /// Clears the scrollback and the screen (the shell redraws its prompt on its own). + pub fn clear(&mut self) { + use ansi::Handler; + self.term.clear_screen(ansi::ClearMode::Saved); + self.feed(b"\x1b[H\x1b[2J"); + } + + pub fn reset(&mut self) { + use ansi::Handler; + self.term.reset_state(); + self.generation += 1; + } + + /// Every line of scrollback and screen as text, for Export Output. + pub fn buffer_text(&self) -> String { + let g = self.term.grid(); + let mut out = String::new(); + for l in g.topmost_line().0..=g.bottommost_line().0 { + out.push_str(self.line_text(Line(l)).0.trim_end()); + out.push('\n'); + } + out + } + + /// Text of a grid line plus, for each char, the column it came from. + fn line_text(&self, line: Line) -> (String, Vec) { + let row = &self.term.grid()[line]; + let (mut s, mut cols) = (String::new(), vec![]); + for c in 0..self.term.columns() { + let cell = &row[Column(c)]; + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + s.push(cell.c); + cols.push(c); + } + (s, cols) + } + + fn color(&self, c: Color, theme: &TermTheme, bold: bool) -> Color32 { + let pal = |i: usize| -> Color32 { + if let Some(rgb) = self.term.colors()[i] { + return Color32::from_rgb(rgb.r, rgb.g, rgb.b); + } + if i < 16 { + return crate::terminal::rgb(theme.ansi[i]); + } + if i < 232 { + let i = i - 16; + let v = |x: usize| if x == 0 { 0 } else { (55 + x * 40) as u8 }; + return Color32::from_rgb(v(i / 36), v((i / 6) % 6), v(i % 6)); + } + let g = (8 + (i - 232) * 10) as u8; + Color32::from_rgb(g, g, g) + }; + match c { + Color::Spec(rgb) => Color32::from_rgb(rgb.r, rgb.g, rgb.b), + Color::Indexed(i) => { + let i = i as usize; + pal(if bold && i < 8 { i + 8 } else { i }) + } + Color::Named(n) => match n { + NamedColor::Foreground | NamedColor::BrightForeground => rgb(theme.foreground), + NamedColor::Background => rgb(theme.background), + NamedColor::Cursor => rgb(theme.cursor), + NamedColor::DimForeground => rgb(theme.foreground).gamma_multiply(0.7), + n => { + let i = n as usize; + if i < 16 { + pal(if bold && i < 8 { i + 8 } else { i }) + } else if (NamedColor::DimBlack as usize..=NamedColor::DimWhite as usize).contains(&i) { + pal(i - NamedColor::DimBlack as usize).gamma_multiply(0.7) + } else { + rgb(theme.foreground) + } + } + }, + } + } + + fn cell_metrics(&self, ui: &Ui) -> (FontId, f32, f32) { + let font = FontId::monospace(self.font_size); + let (w, h) = ui.fonts_mut(|f| (f.glyph_width(&font, 'M'), f.row_height(&font))); + (font, w.max(1.0), h.ceil().max(1.0)) + } + + /// Grid point under a screen position. + fn point_at(&self, origin: Pos2, cw: f32, ch: f32, p: Pos2) -> (Point, Side) { + let col = ((p.x - origin.x) / cw).floor().clamp(0.0, self.cols.saturating_sub(1) as f32); + let row = ((p.y - origin.y) / ch).floor().clamp(0.0, self.rows.saturating_sub(1) as f32); + let side = if (p.x - origin.x) / cw - col > 0.5 { Side::Right } else { Side::Left }; + let line = Line(row as i32 - self.term.grid().display_offset() as i32); + (Point::new(line, Column(col as usize)), side) + } + + pub fn ui(&mut self, ui: &mut Ui, theme: &TermTheme, active: bool) -> TermResponse { + let mut out = TermResponse::default(); + let (font, cw, ch) = self.cell_metrics(ui); + let pad = vec2(6.0, 4.0); + let rect = ui.available_rect_before_wrap(); + let resp = ui.allocate_rect(rect, Sense::click_and_drag()); + let id = resp.id; + let origin = rect.min + pad; + + // Fit the grid to the widget. + let cols = (((rect.width() - 2.0 * pad.x) / cw).floor() as u16).max(2); + let rows = (((rect.height() - 2.0 * pad.y) / ch).floor() as u16).max(1); + if (cols, rows) != (self.cols, self.rows) { + self.cols = cols; + self.rows = rows; + self.term.resize(TermSize::new(cols as usize, rows as usize)); + out.resized = Some((cols, rows)); + } + + if resp.clicked() || resp.drag_started() || (active && self.focus_requested) { + ui.memory_mut(|m| m.request_focus(id)); + self.focus_requested = false; + } + 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 }) + }); + 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 { rect, cursor_rect })); + } + self.mouse(ui, &resp, origin, cw, ch, &mut out); + + // Terminal-originated events. + for e in std::mem::take(&mut *self.events.0.lock().unwrap()) { + match e { + TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()), + TermEvent::Title(t) => out.title = Some(t), + TermEvent::Bell => out.bell = true, + TermEvent::ClipboardStore(_, s) => ui.ctx().copy_text(s), + _ => {} + } + } + + self.paint(ui, rect, origin, &font, cw, ch, theme, focused); + + resp.context_menu(|ui| { + let m = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" }; + let items: [(MenuAction, &str, String); 11] = [ + (MenuAction::Copy, "Copy", format!("{m}C")), + (MenuAction::Paste, "Paste", format!("{m}V")), + (MenuAction::SelectAll, "Select All", String::new()), + (MenuAction::Find, "Find", format!("{m}F")), + (MenuAction::SendSnippet, "Send Snippet", format!("{m}Shift+S")), + (MenuAction::PortForwards, "Port Forwards", String::new()), + (MenuAction::SplitRight, "Split Right", format!("{m}Shift+D")), + (MenuAction::SplitDown, "Split Down", format!("{m}Shift+E")), + (MenuAction::Export, "Export Output", String::new()), + (MenuAction::Clear, "Clear", String::new()), + (MenuAction::Reset, "Reset", String::new()), + ]; + for (i, (a, label, keys)) in items.into_iter().enumerate() { + if matches!(i, 3 | 5 | 6 | 8) { + ui.separator(); + } + if ui.add(egui::Button::new(label).shortcut_text(keys).min_size(vec2(200.0, 0.0))).clicked() { + out.menu = Some(a); + ui.close(); + } + } + }); + if let Some(MenuAction::Copy) = out.menu + && let Some(s) = self.selection_text() + { + ui.ctx().copy_text(s); + out.menu = None; + } + if out.menu == Some(MenuAction::SelectAll) { + self.select_all(); + out.menu = None; + } + out + } + + fn keyboard(&mut self, ui: &mut Ui, out: &mut TermResponse) { + let events = ui.input(|i| i.events.clone()); + let mode = *self.term.mode(); + let alt_meta = !cfg!(target_os = "macos"); + let mut typed = false; + for e in events { + match e { + Event::Copy => match self.selection_text() { + Some(s) => ui.ctx().copy_text(s), + None => out.input.push(3), + }, + Event::Cut => out.input.push(0x18), + Event::Paste(text) => { + self.paste(&text, out); + typed = true; + } + Event::Text(t) => { + if t.chars().any(|c| c.is_control()) { + continue; + } + let alt = ui.input(|i| i.modifiers.alt); + if alt && alt_meta { + out.input.push(0x1b); + } + out.input.extend_from_slice(t.as_bytes()); + typed = true; + } + Event::Ime(egui::ImeEvent::Commit(t)) => { + out.input.extend_from_slice(t.as_bytes()); + typed = true; + } + Event::Key { key, pressed: true, modifiers, .. } => { + // Shift+PageUp/PageDown/Home/End scroll the local scrollback. + if modifiers.shift && !modifiers.ctrl && !mode.contains(TermMode::ALT_SCREEN) { + let page = self.rows as i32 - 1; + let scroll = match key { + Key::PageUp => Some(Scroll::Delta(page)), + Key::PageDown => Some(Scroll::Delta(-page)), + Key::Home => Some(Scroll::Top), + Key::End => Some(Scroll::Bottom), + _ => None, + }; + if let Some(s) = scroll { + self.term.scroll_display(s); + continue; + } + } + if let Some(bytes) = key_bytes(key, modifiers, mode) { + out.input.extend_from_slice(&bytes); + typed = true; + } + } + _ => {} + } + } + if typed { + self.term.selection = None; + self.term.scroll_display(Scroll::Bottom); + } + } + + pub fn paste(&mut self, text: &str, out: &mut TermResponse) { + let text = text.replace("\r\n", "\r").replace('\n', "\r"); + if self.term.mode().contains(TermMode::BRACKETED_PASTE) { + out.input.extend_from_slice(b"\x1b[200~"); + out.input.extend_from_slice(text.replace('\x1b', "").as_bytes()); + out.input.extend_from_slice(b"\x1b[201~"); + } else { + out.input.extend_from_slice(text.as_bytes()); + } + } + + fn mouse(&mut self, ui: &mut Ui, resp: &egui::Response, origin: Pos2, cw: f32, ch: f32, out: &mut TermResponse) { + let mode = *self.term.mode(); + let reporting = mode.intersects(TermMode::MOUSE_MODE) && !ui.input(|i| i.modifiers.shift); + let hover = resp.hover_pos(); + let events = ui.input(|i| i.events.clone()); + let to_cell = |p: Pos2| { + let c = ((p.x - origin.x) / cw).floor().max(0.0) as u32; + let r = ((p.y - origin.y) / ch).floor().max(0.0) as u32; + (c.min(self.cols as u32 - 1), r.min(self.rows as u32 - 1)) + }; + for e in &events { + match e { + Event::MouseWheel { delta, unit, modifiers } if hover.is_some() => { + if modifiers.command { + continue; // left for zoom + } + let lines = match unit { + egui::MouseWheelUnit::Point => delta.y / ch, + egui::MouseWheelUnit::Line => delta.y, + egui::MouseWheelUnit::Page => delta.y * self.rows as f32, + }; + self.wheel_acc += lines; + let n = self.wheel_acc.trunc() as i32; + if n == 0 { + continue; + } + self.wheel_acc -= n as f32; + if reporting { + let (c, r) = to_cell(hover.unwrap()); + for _ in 0..n.unsigned_abs() { + out.input.extend(mouse_report(if n > 0 { 64 } else { 65 }, c, r, true, mode)); + } + } else if mode.contains(TermMode::ALT_SCREEN) { + let seq: &[u8] = if n > 0 { b"\x1b[A" } else { b"\x1b[B" }; + for _ in 0..n.unsigned_abs() { + out.input.extend_from_slice(seq); + } + } else { + self.term.scroll_display(Scroll::Delta(n)); + } + } + Event::PointerButton { pos, button, pressed, modifiers } if resp.rect.contains(*pos) => { + let b = match button { + PointerButton::Primary => 0, + PointerButton::Middle => 1, + PointerButton::Secondary => 2, + _ => continue, + }; + if reporting && b != 2 || reporting && mode.contains(TermMode::MOUSE_MODE) && b == 2 && !*pressed { + let (c, r) = to_cell(*pos); + out.input.extend(mouse_report(b, c, r, *pressed, mode)); + self.mouse_down = pressed.then_some(b); + continue; + } + if b != 0 { + continue; + } + if *pressed { + if modifiers.command && let Some(url) = self.url_at(origin, cw, ch, *pos) { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); + continue; + } + let now = ui.input(|i| i.time); + let (t, n, p) = self.last_click; + let count = if now - t < 0.4 && p.distance(*pos) < 4.0 { n % 3 + 1 } else { 1 }; + self.last_click = (now, count, *pos); + let (point, side) = self.point_at(origin, cw, ch, *pos); + let ty = match count { + 2 => SelectionType::Semantic, + 3 => SelectionType::Lines, + _ => SelectionType::Simple, + }; + self.term.selection = (count > 1 || modifiers.shift).then(|| Selection::new(ty, point, side)); + if modifiers.shift + && let Some(s) = &mut self.term.selection + { + s.update(point, side); + } + self.drag = Some((point, ty)); + } else { + self.drag = None; + } + } + Event::PointerMoved(p) => { + if reporting { + if let Some(b) = self.mouse_down + && (mode.contains(TermMode::MOUSE_DRAG) || mode.contains(TermMode::MOUSE_MOTION)) + && resp.rect.contains(*p) + { + let (c, r) = to_cell(*p); + out.input.extend(mouse_report(b + 32, c, r, true, mode)); + } + continue; + } + if let Some((start, ty)) = self.drag { + let (point, side) = self.point_at(origin, cw, ch, *p); + if self.term.selection.is_none() && point != start { + self.term.selection = Some(Selection::new(ty, start, Side::Left)); + } + if let Some(s) = &mut self.term.selection { + s.update(point, side); + } + // Auto-scroll while dragging past the edges. + if p.y < resp.rect.top() { + self.term.scroll_display(Scroll::Delta(1)); + } else if p.y > resp.rect.bottom() { + self.term.scroll_display(Scroll::Delta(-1)); + } + } + } + _ => {} + } + } + if let Some(p) = hover + && ui.input(|i| i.modifiers.command) + && self.url_at(origin, cw, ch, p).is_some() + { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } else if hover.is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } + } + + fn url_at(&self, origin: Pos2, cw: f32, ch: f32, p: Pos2) -> Option { + let (point, _) = self.point_at(origin, cw, ch, p); + let (text, cols) = self.line_text(point.line); + let re = regex::Regex::new(URL_RE).ok()?; + re.find_iter(&text).find_map(|m| { + let start = text[..m.start()].chars().count(); + let end = start + m.as_str().chars().count() - 1; + (cols.get(start)? <= &point.column.0 && cols.get(end)? >= &point.column.0).then(|| m.as_str().to_string()) + }) + } + + #[allow(clippy::too_many_arguments)] + fn paint(&mut self, ui: &mut Ui, rect: Rect, origin: Pos2, font: &FontId, cw: f32, ch: f32, theme: &TermTheme, focused: bool) { + let painter = ui.painter_at(rect); + let bg_default = rgb(theme.background); + painter.rect_filled(rect, 0.0, bg_default); + let offset = self.term.grid().display_offset() as i32; + 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 ncols = self.cols as usize; + for vr in 0..self.rows as i32 { + let line = Line(vr - offset); + if line.0 > self.term.grid().bottommost_line().0 { + break; + } + let y = origin.y + vr as f32 * ch; + let row = &self.term.grid()[line]; + let mut run = String::new(); + let mut run_x = 0.0; + let mut run_color = Color32::TRANSPARENT; + let flush = |run: &mut String, x: f32, color: Color32| { + if !run.trim().is_empty() { + painter.text(pos2(x, y), egui::Align2::LEFT_TOP, run.as_str(), font.clone(), color); + } + run.clear(); + }; + for c in 0..ncols.min(self.term.columns()) { + let cell = &row[Column(c)]; + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + let bold = cell.flags.contains(Flags::BOLD); + let mut fg = self.color(cell.fg, theme, bold); + let mut bg = self.color(cell.bg, theme, false); + if cell.flags.contains(Flags::INVERSE) { + std::mem::swap(&mut fg, &mut bg); + } + if cell.flags.contains(Flags::DIM) { + fg = fg.gamma_multiply(0.66); + } + let point = Point::new(line, Column(c)); + let width = if cell.flags.contains(Flags::WIDE_CHAR) { 2.0 } else { 1.0 }; + let x = origin.x + c as f32 * cw; + let cell_rect = Rect::from_min_size(pos2(x, y), vec2(cw * width, ch)); + if selection.as_ref().is_some_and(|s| s.contains(point)) { + bg = sel_bg; + } else if let Some(&(_, _, _, cur)) = finds.iter().find(|(l, a, b, _)| *l == line.0 && (*a..=*b).contains(&c)) { + bg = if cur { Color32::from_rgb(255, 160, 40) } else { Color32::from_rgba_unmultiplied(255, 200, 60, 110) }; + if cur { + fg = Color32::BLACK; + } + } + if bg != bg_default { + painter.rect_filled(cell_rect, 0.0, bg); + } + if cell.flags.intersects(Flags::ALL_UNDERLINES) { + painter.hline(cell_rect.x_range(), y + ch - 1.5, Stroke::new(1.0, fg)); + } + if cell.flags.contains(Flags::STRIKEOUT) { + painter.hline(cell_rect.x_range(), y + ch * 0.55, Stroke::new(1.0, fg)); + } + let hidden = cell.flags.contains(Flags::HIDDEN); + let chr = if hidden { ' ' } else { cell.c }; + // ASCII runs share one text call; anything else is placed on its own cell so a + // fallback font's advance can't push later glyphs off the grid. + if chr.is_ascii() && width == 1.0 && fg == run_color && !run.is_empty() { + run.push(chr); + continue; + } + flush(&mut run, run_x, run_color); + if chr.is_ascii() && width == 1.0 { + run.push(chr); + run_x = x; + run_color = fg; + } else if chr != ' ' { + let mut s = String::from(chr); + if let Some(z) = cell.zerowidth() { + s.extend(z); + } + painter.text(cell_rect.center(), egui::Align2::CENTER_CENTER, s, font.clone(), fg); + } + } + flush(&mut run, run_x, run_color); + } + + // Cursor. + let mode = *self.term.mode(); + let cur = self.term.grid().cursor.point; + let vr = cur.line.0 + offset; + if mode.contains(TermMode::SHOW_CURSOR) && (0..self.rows as i32).contains(&vr) { + let blink_on = !focused || (ui.input(|i| i.time) * 1.9) as i64 % 2 == 0; + if focused { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(530)); + } + let r = Rect::from_min_size(pos2(origin.x + cur.column.0 as f32 * cw, origin.y + vr as f32 * ch), vec2(cw, ch)); + let c = rgb(theme.cursor); + let shape = match self.term.cursor_style().shape { + CursorShape::Underline => CursorStyle::Underline, + CursorShape::Beam => CursorStyle::Bar, + CursorShape::Block if self.term.cursor_style() == alacritty_terminal::vte::ansi::CursorStyle::default() => self.cursor, + _ => CursorStyle::Block, + }; + if !focused { + painter.rect_stroke(r, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Inside); + } else if blink_on { + match shape { + CursorStyle::Block => { + painter.rect_filled(r, 0.0, c); + let cell = &self.term.grid()[cur.line][cur.column]; + if cell.c != ' ' { + painter.text(r.left_top(), egui::Align2::LEFT_TOP, cell.c, font.clone(), rgb(theme.cursor_accent)); + } + } + CursorStyle::Underline => painter.rect_filled(Rect::from_min_max(pos2(r.left(), r.bottom() - 2.0), r.max), 0.0, c), + CursorStyle::Bar => painter.rect_filled(Rect::from_min_size(r.min, vec2(2.0, ch)), 0.0, c), + } + } + } + // Scroll position indicator when looking at history. + if offset > 0 { + let total = (self.term.grid().history_size() + self.rows as usize) as f32; + let h = (rect.height() * self.rows as f32 / total).max(16.0); + let top = rect.top() + (rect.height() - h) * (1.0 - offset as f32 / self.term.grid().history_size().max(1) as f32); + painter.rect_filled(Rect::from_min_size(pos2(rect.right() - 5.0, top), vec2(3.0, h)), 2.0, rgb(theme.foreground).gamma_multiply(0.35)); + } + } + + // --- find --------------------------------------------------------------------------- + + fn refresh_find(&mut self) { + let generation = self.generation; + let Some(f) = &self.find else { return }; + let key = (f.query.clone(), f.case_sensitive, f.regex, generation); + if f.computed_for.as_ref() == Some(&key) { + return; + } + let pattern = if f.regex { f.query.clone() } else { regex::escape(&f.query) }; + let pattern = if f.case_sensitive { pattern } else { format!("(?i){pattern}") }; + let mut matches = vec![]; + let mut error = None; + if !f.query.is_empty() { + match regex::Regex::new(&pattern) { + Ok(re) => { + let g = self.term.grid(); + for l in g.topmost_line().0..=g.bottommost_line().0 { + let (text, cols) = self.line_text(Line(l)); + for m in re.find_iter(&text) { + if m.as_str().is_empty() { + continue; + } + let a = text[..m.start()].chars().count(); + let b = a + m.as_str().chars().count() - 1; + if let (Some(&ca), Some(&cb)) = (cols.get(a), cols.get(b)) { + matches.push((l, ca, cb)); + } + } + } + } + Err(e) => error = Some(e.to_string()), + } + } + let f = self.find.as_mut().unwrap(); + let had = f.matches.len(); + f.matches = matches; + f.error = error; + if had == 0 || f.current >= f.matches.len() { + f.current = f.matches.len().saturating_sub(1); + } + f.computed_for = Some(key); + } + + /// Moves to the next (or previous) match and scrolls it into view. + pub fn find_step(&mut self, forward: bool) { + self.refresh_find(); + let Some(f) = &mut self.find else { return }; + if f.matches.is_empty() { + return; + } + let n = f.matches.len(); + f.current = if forward { (f.current + 1) % n } else { (f.current + n - 1) % n }; + let line = f.matches[f.current].0; + self.reveal(line); + } + + fn reveal(&mut self, line: i32) { + let offset = self.term.grid().display_offset() as i32; + let rows = self.rows as i32; + let vr = line + offset; + if !(0..rows).contains(&vr) { + let target = (rows / 2 - line).clamp(0, self.term.grid().history_size() as i32); + self.term.scroll_display(Scroll::Delta(target - offset)); + } + } + + pub fn find_counts(&self) -> (usize, usize) { + self.find.as_ref().map_or((0, 0), |f| (if f.matches.is_empty() { 0 } else { f.current + 1 }, f.matches.len())) + } +} + +/// xterm encoding of a mouse event at 0-based cell (c, r). +fn mouse_report(button: u8, c: u32, r: u32, pressed: bool, mode: TermMode) -> Vec { + if mode.contains(TermMode::SGR_MOUSE) { + format!("\x1b[<{button};{};{}{}", c + 1, r + 1, if pressed { 'M' } else { 'm' }).into_bytes() + } else { + let b = if pressed { button } else { 3 }; + let enc = |v: u32| (32 + v + 1).min(255) as u8; + vec![0x1b, b'[', b'M', 32 + b, enc(c), enc(r)] + } +} + +/// Bytes an xterm sends for a non-text key. +pub fn key_bytes(key: Key, m: Modifiers, mode: TermMode) -> Option> { + let app_cursor = mode.contains(TermMode::APP_CURSOR); + let mods = 1 + (m.shift as u8) + 2 * (m.alt as u8) + 4 * (m.ctrl as u8); + let csi = |final_: char| -> Vec { + if mods > 1 { + format!("\x1b[1;{mods}{final_}").into_bytes() + } else if app_cursor { + format!("\x1bO{final_}").into_bytes() + } else { + format!("\x1b[{final_}").into_bytes() + } + }; + let tilde = |n: u8| -> Vec { if mods > 1 { format!("\x1b[{n};{mods}~") } else { format!("\x1b[{n}~") }.into_bytes() }; + let ss3 = |final_: char| -> Vec { if mods > 1 { format!("\x1b[1;{mods}{final_}").into_bytes() } else { format!("\x1bO{final_}").into_bytes() } }; + let esc = |b: &[u8]| -> Vec { + let mut v = if m.alt { vec![0x1b] } else { vec![] }; + v.extend_from_slice(b); + v + }; + Some(match key { + Key::Enter => esc(b"\r"), + Key::Backspace => esc(if m.ctrl { b"\x08" } else { b"\x7f" }), + Key::Tab if m.shift => b"\x1b[Z".to_vec(), + Key::Tab => b"\t".to_vec(), + Key::Escape => b"\x1b".to_vec(), + Key::ArrowUp => csi('A'), + Key::ArrowDown => csi('B'), + Key::ArrowRight => csi('C'), + Key::ArrowLeft => csi('D'), + Key::Home => csi('H'), + Key::End => csi('F'), + Key::Insert => tilde(2), + Key::Delete => tilde(3), + Key::PageUp => tilde(5), + Key::PageDown => tilde(6), + Key::F1 => ss3('P'), + Key::F2 => ss3('Q'), + Key::F3 => ss3('R'), + Key::F4 => ss3('S'), + Key::F5 => tilde(15), + Key::F6 => tilde(17), + Key::F7 => tilde(18), + Key::F8 => tilde(19), + Key::F9 => tilde(20), + Key::F10 => tilde(21), + Key::F11 => tilde(23), + Key::F12 => tilde(24), + _ if m.ctrl && !m.command_only() => { + let b = match key { + Key::Space | Key::Num2 => 0, + Key::OpenBracket | Key::Num3 => 0x1b, + Key::Backslash | Key::Num4 => 0x1c, + Key::CloseBracket | Key::Num5 => 0x1d, + Key::Num6 => 0x1e, + Key::Slash | Key::Minus | Key::Num7 => 0x1f, + Key::Num8 => 0x7f, + k => { + let name = k.name(); + let c = name.chars().next().filter(|c| name.len() == 1 && c.is_ascii_alphabetic())?; + (c.to_ascii_uppercase() as u8) & 0x1f + } + }; + esc(&[b]) + } + _ if m.alt && !m.ctrl && !cfg!(target_os = "macos") => return None, // arrives as Text with ESC prefix + _ => return None, + }) +} + +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 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_encode_like_xterm() { + let none = Modifiers::NONE; + assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::empty()).unwrap(), b"\x1b[A"); + 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]); + 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()); + } + + #[test] + fn feeds_text_and_finds_it() { + let mut t = TermView::new(1000, 14.0, CursorStyle::Block); + t.feed(b"hello \x1b[31mworld\x1b[0m\r\nsecond world\r\n"); + assert!(t.buffer_text().starts_with("hello world\nsecond world")); + t.find = Some(Find { query: "WORLD".into(), ..Default::default() }); + t.refresh_find(); + assert_eq!(t.find_counts().1, 2); + t.find.as_mut().unwrap().case_sensitive = true; + t.refresh_find(); + assert_eq!(t.find_counts().1, 0); + } + + #[test] + fn replies_to_device_attributes() { + let mut t = TermView::new(10, 14.0, CursorStyle::Block); + t.feed(b"\x1b[6n"); + let ev = t.events.0.lock().unwrap(); + assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R"))); + } + + #[test] + fn mouse_reports() { + assert_eq!(mouse_report(0, 0, 0, true, TermMode::SGR_MOUSE), b"\x1b[<0;1;1M"); + assert_eq!(mouse_report(0, 2, 3, true, TermMode::empty()), vec![0x1b, b'[', b'M', 32, 35, 36]); + } +} diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index dd28272..b198b60 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -24,5 +24,11 @@ sha1 = "0.11.0" portable-pty = "0.9.0" serialport = { version = "4", default-features = false } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +webpki-roots = "1" + +[dev-dependencies] +tiny_http = "0.12" + [target.'cfg(target_os = "linux")'.dev-dependencies] libc = "0.2" diff --git a/rust/core/src/clients/github.rs b/rust/core/src/clients/github.rs new file mode 100644 index 0000000..462e7a5 --- /dev/null +++ b/rust/core/src/clients/github.rs @@ -0,0 +1,555 @@ +//! Lightweight GitHub REST client for repository metadata and archive downloads. Works with +//! github.com and GitHub Enterprise via a configurable API base URL. + +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use chrono::{DateTime, FixedOffset, SecondsFormat}; +use serde::Deserialize; + +use crate::models::GitHubRepo; + +const MAX_JSON_BODY_BYTES: u64 = 4 << 20; +const MAX_ERROR_BODY_BYTES: u64 = 64 << 10; +const MAX_ARCHIVE_BYTES: u64 = 2 << 30; +const USER_AGENT: &str = "NexusTerminal/1.0"; +const COMMIT_LOOKUP_WORKERS: usize = 5; + +pub struct Client { + base_url: String, + token: String, + agent: ureq::Agent, +} + +#[derive(Deserialize)] +struct ApiRepo { + #[serde(default)] + full_name: Option, + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + html_url: Option, + #[serde(default)] + default_branch: Option, + #[serde(default)] + private: Option, + #[serde(default)] + fork: Option, + #[serde(default)] + archived: Option, + #[serde(default)] + language: Option, + #[serde(default)] + stargazers_count: Option, + #[serde(default)] + pushed_at: Option, + #[serde(default)] + updated_at: Option, + #[serde(default)] + owner: Option, +} + +#[derive(Deserialize)] +struct ApiOwner { + #[serde(default)] + login: Option, +} + +#[derive(Deserialize, Default)] +struct ApiPerson { + #[serde(default)] + name: Option, + #[serde(default)] + date: Option, +} + +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(); + Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent } + } + + fn get(&self, url: &str) -> Result, ureq::Error> { + self.agent + .get(url) + .header("Authorization", &format!("Bearer {}", self.token)) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", USER_AGENT) + .call() + } + + /// Confirms the token works and returns the authenticated login. + pub fn validate_token(&self) -> crate::Result { + 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}"))?; + #[derive(Deserialize)] + struct User { + #[serde(default)] + login: Option, + } + let user: User = serde_json::from_slice(&body).map_err(|e| format!("decoding user: {e}"))?; + Ok(user.login.unwrap_or_default()) + } + + /// All repositories visible to the token, newest push first, enriched with latest-commit info. + pub fn fetch_repos(&self) -> crate::Result> { + self.fetch_repos_with_progress(|_, _| {}) + } + + /// 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> { + let mut all: Vec = 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 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 status = resp.status().as_u16(); + if status == 401 || status == 403 { + 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()); + } + let batch: Vec = + serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?; + if batch.is_empty() { + break; + } + let short = batch.len() < 100; + all.extend(batch); + // Safety cap — 5000 repos should be more than enough. + if all.len() >= 5000 || short { + break; + } + page += 1; + } + + let mut parsed = Vec::with_capacity(all.len()); + for r in all { + let pushed = parse_time(r.pushed_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?; + 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)); + + let mut result: Vec = parsed + .into_iter() + .map(|(r, pushed, updated)| GitHubRepo { + full_name: r.full_name.unwrap_or_default(), + name: r.name.unwrap_or_default(), + owner: r.owner.and_then(|o| o.login).unwrap_or_default(), + description: r.description.unwrap_or_default(), + html_url: r.html_url.unwrap_or_default(), + default_branch: r.default_branch.unwrap_or_default(), + private: r.private.unwrap_or_default(), + fork: r.fork.unwrap_or_default(), + archived: r.archived.unwrap_or_default(), + language: r.language.unwrap_or_default(), + stars: r.stargazers_count.unwrap_or_default(), + pushed_at: format_time(pushed), + updated_at: format_time(updated), + ..Default::default() + }) + .collect(); + + let total = result.len(); + progress(0, total); + + // Enrich with latest commit metadata (best-effort), five lookups at a time. + let next = AtomicUsize::new(0); + let done = AtomicUsize::new(0); + let lookups: Vec<(usize, String, String)> = result + .iter() + .enumerate() + .map(|(i, r)| { + let reference = if r.default_branch.is_empty() { "HEAD" } else { &r.default_branch }; + (i, r.full_name.clone(), reference.to_string()) + }) + .collect(); + let found: Vec<(usize, String, String)> = std::thread::scope(|s| { + let workers: Vec<_> = (0..COMMIT_LOOKUP_WORKERS.min(total)) + .map(|_| { + s.spawn(|| { + let mut out = Vec::new(); + loop { + let i = next.fetch_add(1, Ordering::SeqCst); + let Some((idx, name, reference)) = lookups.get(i) else { break }; + if let Ok((who, when)) = self.fetch_latest_commit(name, reference) { + out.push((*idx, who, when)); + } + progress(done.fetch_add(1, Ordering::SeqCst) + 1, total); + } + out + }) + }) + .collect(); + workers.into_iter().flat_map(|w| w.join().unwrap_or_default()).collect() + }); + for (idx, who, when) in found { + result[idx].latest_committer = who; + result[idx].latest_commit_at = when; + } + Ok(result) + } + + /// Committer name and RFC 3339 commit time for a repo and ref (`HEAD` when empty). + pub fn fetch_latest_commit(&self, full_name: &str, reference: &str) -> crate::Result<(String, String)> { + let reference = if reference.is_empty() { "HEAD" } else { reference }; + let (owner, repo) = split_repo_full_name(full_name)?; + let url = self.endpoint_url(&["repos", &owner, &repo, "commits", reference])?; + let mut resp = self.get(&url).map_err(|e| format!("fetching commit: {e}"))?; + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + 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()); + } + 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 { + #[serde(default)] + commit: Commit, + } + #[derive(Deserialize, Default)] + struct Commit { + #[serde(default)] + committer: Option, + #[serde(default)] + author: Option, + } + let payload: Payload = serde_json::from_slice(&body).map_err(|e| format!("decoding commit: {e}"))?; + let committer = payload.commit.committer.unwrap_or_default(); + let author = payload.commit.author.unwrap_or_default(); + let (mut name, mut date) = (committer.name.unwrap_or_default(), committer.date); + if name.is_empty() { + name = author.name.unwrap_or_default(); + date = author.date; + } + let date = parse_time(date.as_deref()).map_err(|e| format!("decoding commit: {e}"))?; + match date { + Some(d) if !name.is_empty() => Ok((name, format_time(Some(d)))), + _ => Err("commit metadata missing".into()), + } + } + + /// Downloads the repository zipball (default branch when `reference` is empty) to `dest_path`. + pub fn download_repo_archive(&self, full_name: &str, reference: &str, dest_path: &Path) -> crate::Result<()> { + let (owner, repo) = split_repo_full_name(full_name)?; + let mut segments = vec!["repos", &owner, &repo, "zipball"]; + if !reference.is_empty() { + segments.push(reference); + } + let url = self.endpoint_url(&segments)?; + let mut resp = self.get(&url).map_err(|e| format!("downloading archive: {e}"))?; + let status = resp.status().as_u16(); + if status == 401 || status == 403 { + 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()); + } + let length = resp + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.trim().parse::().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() + ); + } + 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}"))?; + Ok(()) + } + + /// Base URL plus path-escaped segments. + fn endpoint_url(&self, segments: &[&str]) -> crate::Result { + let mut url = self.base_url.clone(); + for segment in segments { + if segment.is_empty() { + return Err("empty GitHub API path segment".into()); + } + url.push('/'); + url.push_str(&path_escape(segment)); + } + Ok(url) + } +} + +/// Go's `url.PathEscape`. +fn path_escape(segment: &str) -> String { + let mut out = String::new(); + for b in segment.bytes() { + if b.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +fn parse_time(value: Option<&str>) -> Result>, String> { + match value { + None => Ok(None), + Some(v) => { + let t = DateTime::parse_from_rfc3339(v).map_err(|e| format!("parsing time {v:?}: {e}"))?; + // Go's zero time means "unset". + Ok(Some(t).filter(|t| t.timestamp() != -62135596800)) + } + } +} + +/// Go's `time.Time.Format(time.RFC3339)`, with the zero time for a missing value. +fn format_time(value: Option>) -> String { + match value { + Some(t) => t.to_rfc3339_opts(SecondsFormat::Secs, true), + None => "0001-01-01T00:00:00Z".to_string(), + } +} + +fn read_limited(r: impl Read, max_bytes: u64) -> crate::Result> { + let mut body = Vec::new(); + r.take(max_bytes + 1).read_to_end(&mut body)?; + if body.len() as u64 > max_bytes { + return Err(format!("response body exceeds {max_bytes} bytes").into()); + } + Ok(body) +} + +fn read_error_body(r: impl Read) -> String { + match read_limited(r, MAX_ERROR_BODY_BYTES) { + Ok(body) => String::from_utf8_lossy(&body).trim().to_string(), + Err(e) => format!(""), + } +} + +fn split_repo_full_name(full_name: &str) -> crate::Result<(String, String)> { + let parts: Vec<&str> = full_name.trim().split('/').collect(); + let [owner, repo] = parts.as_slice() else { + return Err("repository name must be in owner/repo format".into()); + }; + let (owner, repo) = (owner.trim(), repo.trim()); + if owner.is_empty() || repo.is_empty() { + return Err("repository name must be in owner/repo format".into()); + } + Ok((owner.to_string(), repo.to_string())) +} + +/// Copies at most `max_bytes`; returns the number copied, or an error once the source exceeds it. +fn copy_limited_archive(dst: &mut impl Write, src: impl Read, max_bytes: u64) -> crate::Result { + let n = std::io::copy(&mut src.take(max_bytes + 1), dst)?; + if n > max_bytes { + return Err(format!("archive exceeds {max_bytes} bytes").into()); + } + Ok(n) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[test] + fn read_limited_rejects_oversized_response() { + let err = read_limited(&b"abcd"[..], 3).unwrap_err(); + assert!(err.to_string().contains("response body exceeds"), "{err}"); + } + + #[test] + fn read_limited_allows_response_at_limit() { + assert_eq!(read_limited(&b"abc"[..], 3).unwrap(), b"abc"); + } + + #[test] + fn endpoint_url_escapes_repo_path_segments() { + let client = Client::new("https://ghe.example.com/api/v3", ""); + let got = client.endpoint_url(&["repos", "owner", "repo", "commits", "feature/a b"]).unwrap(); + assert_eq!(got, "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b"); + assert!(client.endpoint_url(&["repos", ""]).is_err()); + } + + #[test] + fn split_repo_full_name_rejects_extra_segments() { + assert!(split_repo_full_name("owner/repo/../../user").is_err()); + assert!(split_repo_full_name("owner/").is_err()); + assert_eq!(split_repo_full_name(" a / b ").unwrap(), ("a".into(), "b".into())); + } + + #[test] + fn copy_limited_archive_rejects_oversized_stream() { + let mut dst = Vec::new(); + let err = copy_limited_archive(&mut dst, &b"abcd"[..], 3).unwrap_err(); + assert!(err.to_string().contains("archive exceeds"), "{err}"); + assert_eq!(dst.len(), 4); + } + + #[test] + fn copy_limited_archive_allows_stream_at_limit() { + let mut dst = Vec::new(); + assert_eq!(copy_limited_archive(&mut dst, &b"abc"[..], 3).unwrap(), 3); + assert_eq!(dst, b"abc"); + } + + type Seen = Arc, Option)>>>; + + /// A local HTTP server answering with `handler(url) -> (status, body)`; records + /// (url, authorization, user-agent) for every request. + fn serve(handler: impl Fn(&str) -> (u16, String) + Send + 'static) -> (String, Seen) { + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let base = format!("http://{}", server.server_addr().to_ip().unwrap()); + let seen: Seen = Arc::default(); + 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()) + }; + 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)); + } + }); + (base, seen) + } + + #[test] + fn validate_token_returns_login_and_reports_status() { + let (base, seen) = serve(|_| (200, r#"{"login":"octocat"}"#.into())); + assert_eq!(Client::new(&base, "tok").validate_token().unwrap(), "octocat"); + let (url, auth, ua) = seen.lock().unwrap()[0].clone(); + assert_eq!(url, "/user"); + assert_eq!(auth.as_deref(), Some("Bearer tok")); + assert_eq!(ua.as_deref(), Some(USER_AGENT)); + + let (base, _) = serve(|_| (401, "{}".into())); + let err = Client::new(&base, "bad").validate_token().unwrap_err(); + assert_eq!(err.to_string(), "token validation failed (HTTP 401)"); + } + + fn repo_json(n: usize, pushed: &str) -> serde_json::Value { + 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, + "stargazers_count": n, "pushed_at": pushed, "updated_at": "2024-01-01T00:00:00Z", + "owner": {"login": "me"} + }) + } + + #[test] + 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(); + (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()) + } else if url == "/repos/me/r100/commits/main" { + (200, r#"{"commit":{"committer":{"name":"","date":"0001-01-01T00:00:00Z"},"author":{"name":"Ann","date":"2025-06-01T11:00:00Z"}}}"#.into()) + } else if url.starts_with("/repos/me/r0/commits/HEAD") { + (200, r#"{"commit":{"committer":{"name":"Bob","date":"2024-01-05T10:00:00+00:00"}}}"#.into()) + } else { + (404, "nope".into()) + } + }); + 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(); + assert_eq!(repos.len(), 101); + assert_eq!(repos[0].full_name, "me/r100"); + assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z"); + assert_eq!(repos[0].latest_committer, "Ann"); + assert_eq!(repos[0].latest_commit_at, "2025-06-01T11:00:00Z"); + assert!(repos.windows(2).all(|w| w[0].pushed_at >= w[1].pushed_at)); + let r0 = repos.iter().find(|r| r.name == "r0").unwrap(); + assert_eq!((r0.latest_committer.as_str(), r0.latest_commit_at.as_str()), ("Bob", "2024-01-05T10:00:00Z")); + assert_eq!(r0.owner, "me"); + assert!(r0.private && r0.description.is_empty() && r0.language.is_empty()); + assert!(repos.iter().filter(|r| r.latest_committer.is_empty()).count() == 99); + + let mut calls = calls.into_inner().unwrap(); + assert_eq!(calls[0], (0, 101)); + calls.sort(); + assert_eq!(calls.last(), Some(&(101, 101))); + assert_eq!(calls.len(), 102); + assert_eq!(seen.lock().unwrap().iter().filter(|s| s.0.starts_with("/user/repos")).count(), 2); + } + + #[test] + fn fetch_repos_reports_auth_and_api_errors() { + let (base, _) = serve(|_| (403, "{}".into())); + let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string(); + assert_eq!(err, "authentication failed (HTTP 403) — check your access token"); + + let (base, _) = serve(|_| (500, "boom".into())); + let err = Client::new(&base, "t").fetch_repos().unwrap_err().to_string(); + assert_eq!(err, "GitHub API returned HTTP 500: boom"); + } + + #[test] + fn download_repo_archive_writes_file() { + let (base, seen) = serve(|url| match url { + "/repos/me/r/zipball/v1%2F2" => (200, "ZIPDATA".into()), + _ => (404, " missing \n".into()), + }); + let dir = std::env::temp_dir().join(format!("nexus-gh-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("a.zip"); + let client = Client::new(&base, "t"); + client.download_repo_archive("me/r", "v1/2", &dest).unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"ZIPDATA"); + let err = client.download_repo_archive("me/r", "", &dest).unwrap_err().to_string(); + assert_eq!(err, "GitHub API returned HTTP 404: missing"); + assert_eq!(seen.lock().unwrap()[1].0, "/repos/me/r/zipball"); + assert!(client.download_repo_archive("bad", "", &dest).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/rust/core/src/clients/irc.rs b/rust/core/src/clients/irc.rs new file mode 100644 index 0000000..3fdf209 --- /dev/null +++ b/rust/core/src/clients/irc.rs @@ -0,0 +1,1337 @@ +//! IRC client: registration, PING/PONG, channel/user tracking, CTCP ACTION, NickServ identify, +//! auto-join and the composer's /commands, reporting through typed `Event`s. +//! +//! One I/O thread owns the transport (TCP, or a caller-supplied stream such as an SSH tunnel, +//! optionally wrapped in TLS). It alternates between short reads and draining the outgoing +//! queue, so the transport never has to be split. + +use std::collections::HashMap; +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use super::{CloseFn, ErrorFn}; + +pub const SERVER_TARGET: &str = "server"; +const MAX_ROOM_LIST_ENTRIES: usize = 2000; +const QUIT_MESSAGE: &str = "Nexus Terminal disconnected"; +const READ_POLL: Duration = Duration::from_millis(50); +const PING_DELAY: Duration = Duration::from_secs(20); +const PING_TIMEOUT: Duration = Duration::from_secs(60); +const PING_GRACE: Duration = Duration::from_secs(30); +const MAX_LINE_BYTES: usize = 1 << 20; + +/// A bidirectional byte stream. Reads must not block indefinitely: they should return +/// `WouldBlock` or `TimedOut` after a short interval (tens of milliseconds) so the client's +/// I/O thread can interleave writes. +pub trait ReadWrite: Read + Write + Send {} +impl ReadWrite for T {} + +pub type Connector = Arc crate::Result> + Send + Sync>; +pub type EventFn = Arc; +pub type RoomsChangedFn = Arc) + Send + Sync>; + +#[derive(Clone)] +pub struct Config { + pub server: String, + pub port: u16, + pub nick: String, + pub user: String, + pub real_name: String, + pub use_tls: bool, + pub server_pass: String, + pub auto_join_rooms: Vec, + + pub nickserv_identify: bool, + pub nickserv_account: String, + pub nickserv_password: String, + + /// Provides the transport instead of a direct TCP connect (e.g. an SSH tunnel). TLS, when + /// enabled, is layered on top of it. + pub connector: Option, + + pub connect_timeout: Duration, + pub on_event: EventFn, + pub on_error: ErrorFn, + pub on_close: CloseFn, + pub on_rooms_changed: RoomsChangedFn, +} + +impl Default for Config { + fn default() -> Config { + Config { + server: String::new(), + port: 6667, + nick: String::new(), + user: String::new(), + real_name: String::new(), + use_tls: false, + server_pass: String::new(), + auto_join_rooms: Vec::new(), + nickserv_identify: false, + nickserv_account: String::new(), + nickserv_password: String::new(), + connector: None, + connect_timeout: Duration::ZERO, + on_event: Arc::new(|_| {}), + on_error: Arc::new(|_| {}), + on_close: Arc::new(|| {}), + on_rooms_changed: Arc::new(|_| {}), + } + } +} + +/// A typed IRC event, serialised for the UI exactly as Go's `ircclient.Event`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Event { + #[serde(rename = "type")] + pub kind: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub target: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub from: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub text: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub command: String, + pub timestamp: i64, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub rooms: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub users: Vec, + #[serde(skip_serializing_if = "String::is_empty")] + pub topic: String, +} + +/// A joined or listed IRC room. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RoomInfo { + pub name: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub topic: String, + #[serde(skip_serializing_if = "is_zero")] + pub users: i64, +} + +fn is_zero(n: &i64) -> bool { + *n == 0 +} + +enum Out { + Line(String), + Quit(String), +} + +struct Channel { + name: String, + topic: String, + users: Vec, +} + +struct State { + nick: String, + connected: bool, + closing: bool, + list_rooms: Vec, + room_names: Vec, + /// Keyed by RFC 1459 casefolded name. + channels: HashMap, + ready: Option>>, + last_pong: Instant, +} + +pub struct Client { + cfg: Config, + state: Mutex, + out: Mutex>>, +} + +/// A parsed server line. +struct Message { + source: String, + command: String, + params: Vec, +} + +impl Message { + fn last(&self) -> &str { + self.params.last().map(String::as_str).unwrap_or("") + } + fn param(&self, i: usize) -> &str { + self.params.get(i).map(String::as_str).unwrap_or("") + } +} + +impl Client { + pub fn new(mut cfg: Config) -> Arc { + if cfg.connect_timeout.is_zero() { + cfg.connect_timeout = Duration::from_secs(30); + } + if cfg.user.is_empty() { + cfg.user = cfg.nick.clone(); + } + if cfg.real_name.is_empty() { + cfg.real_name = cfg.nick.clone(); + } + let nick = cfg.nick.clone(); + Arc::new(Client { + cfg, + state: Mutex::new(State { + nick, + connected: false, + closing: false, + list_rooms: Vec::new(), + room_names: Vec::new(), + channels: HashMap::new(), + ready: None, + last_pong: Instant::now(), + }), + out: Mutex::new(None), + }) + } + + fn st(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Connects and blocks until registration succeeds (RPL_WELCOME), fails, or times out. + pub fn connect(self: &Arc) -> crate::Result<()> { + let (ready_tx, ready_rx) = mpsc::channel(); + let (out_tx, out_rx) = mpsc::channel(); + self.st().ready = Some(ready_tx); + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = Some(out_tx); + + let client = self.clone(); + std::thread::spawn(move || client.io_thread(out_rx)); + + let timeout = self.cfg.connect_timeout; + match ready_rx.recv_timeout(timeout) { + Ok(Ok(())) => Ok(()), + Ok(Err(e)) => Err(e.into()), + Err(_) => { + self.close(); + Err(format!("IRC connection timed out after {timeout:?}").into()) + } + } + } + + /// Sends QUIT and disconnects. Idempotent; `on_close` fires from the I/O thread. + pub fn close(&self) { + { + let mut st = self.st(); + if st.closing { + return; + } + st.closing = true; + } + self.queue(Out::Quit(QUIT_MESSAGE.to_string())); + } + + fn queue(&self, out: Out) -> bool { + let guard = self.out.lock().unwrap_or_else(|e| e.into_inner()); + guard.as_ref().is_some_and(|tx| tx.send(out).is_ok()) + } + + /// Sends one IRC line (CR/LF are stripped). + fn send_raw(&self, line: &str) -> crate::Result<()> { + let line: String = line.chars().filter(|c| *c != '\r' && *c != '\n').collect(); + if self.queue(Out::Line(line)) { Ok(()) } else { Err("not connected".into()) } + } + + fn send(&self, command: &str, params: &[&str]) -> crate::Result<()> { + self.send_raw(&encode(command, params)) + } + + /// PRIVMSG/NOTICE, split so each line fits the 512-byte limit. + fn send_text(&self, command: &str, target: &str, text: &str) -> crate::Result<()> { + // ponytail: fixed prefix allowance (nick!user@host) like girc's default; no ISUPPORT tuning. + let max = 510usize.saturating_sub(115 + command.len() + target.len() + 3).max(64); + let mut rest = text; + while !rest.is_empty() { + let mut cut = rest.len().min(max); + while !rest.is_char_boundary(cut) { + cut -= 1; + } + self.send(command, &[target, &rest[..cut]])?; + rest = &rest[cut..]; + } + Ok(()) + } + + fn nick(&self) -> String { + self.st().nick.clone() + } + + /// Parses a composer line: plain text goes to `target`, `/commands` are interpreted. + pub fn handle_input(&self, target: &str, input: &str) -> crate::Result<()> { + let text = input.trim(); + if text.is_empty() { + return Ok(()); + } + let target = if target.is_empty() { SERVER_TARGET } else { target }; + + if !text.starts_with('/') || text == "/" { + if target == SERVER_TARGET { + return Err("select a room or use /msg ".into()); + } + self.send_text("PRIVMSG", target, text)?; + self.emit_local_message("message", target, text); + return Ok(()); + } + + let command_line = text[1..].trim(); + let Some(first) = command_line.split_whitespace().next() else { return Ok(()) }; + let cmd = first.to_lowercase(); + let args = command_line[first.len()..].trim(); + + match cmd.as_str() { + "me" | "action" => { + if target == SERVER_TARGET { + return Err("/me requires an active room or private tab".into()); + } + if args.is_empty() { + return Err("/me requires an action".into()); + } + self.send("PRIVMSG", &[target, &format!("\x01ACTION {args}\x01")])?; + self.emit_local_message("action", target, args); + } + "msg" | "query" => { + let (recipient, body) = split_first_arg(args); + if recipient.is_empty() || body.is_empty() { + return Err("/msg requires a target and message".into()); + } + self.send_text("PRIVMSG", recipient, body)?; + self.emit_local_message("message", recipient, body); + } + "notice" => { + let (recipient, body) = split_first_arg(args); + if recipient.is_empty() || body.is_empty() { + return Err("/notice requires a target and message".into()); + } + self.send_text("NOTICE", recipient, body)?; + self.emit_local_message("notice", recipient, body); + } + "join" | "j" => { + let (room, key) = split_first_arg(args); + if room.is_empty() { + return Err("/join requires a room".into()); + } + if key.is_empty() { + self.send("JOIN", &[room])?; + } else { + self.send("JOIN", &[room, key])?; + } + } + "part" | "leave" => { + let (room, msg) = split_first_arg(args); + let room = if room.is_empty() { target } else { room }; + if room == SERVER_TARGET { + return Err("/part requires a room".into()); + } + if msg.is_empty() { + self.send("PART", &[room])?; + } else { + self.send("PART", &[room, msg])?; + } + } + "quit" => { + let msg = if args.is_empty() { QUIT_MESSAGE } else { args }; + self.queue(Out::Quit(msg.to_string())); + } + "nick" => { + let Some(nick) = args.split_whitespace().next() else { + return Err("/nick requires a nickname".into()); + }; + self.send("NICK", &[nick])?; + } + "topic" => { + let (room, topic) = split_first_arg(args); + let room = if room.is_empty() { target } else { room }; + if room == SERVER_TARGET { + return Err("/topic requires a room".into()); + } + if topic.is_empty() { + self.send("TOPIC", &[room])?; + } else { + self.send("TOPIC", &[room, topic])?; + } + } + "list" => { + if args.is_empty() { + self.send("LIST", &[])?; + } else { + self.send("LIST", &[&args.split_whitespace().collect::>().join(",")])?; + } + } + "names" => { + let room = if args.is_empty() && target != SERVER_TARGET { target } else { args }; + if room.is_empty() { + return self.send_raw("NAMES"); + } + return self.send_raw(&format!("NAMES {room}")); + } + "whois" => { + if args.is_empty() { + return Err("/whois requires a nickname".into()); + } + for nick in args.split_whitespace() { + self.send("WHOIS", &[nick])?; + } + } + "who" => { + let args = if args.is_empty() { + if target == SERVER_TARGET { + return Err("/who requires a target".into()); + } + target + } else { + args + }; + for who in args.split_whitespace() { + self.send("WHO", &[who, "%tcuhnr,2"])?; + } + } + "raw" | "quote" => { + if args.is_empty() { + return Err("/raw requires an IRC command".into()); + } + return self.send_raw(args); + } + "help" => self.emit(Event { + kind: "system".into(), + target: target.into(), + text: "Commands: /join, /part, /msg, /notice, /me, /nick, /topic, /list, /names, /whois, /who, /raw, /quit".into(), + ..Default::default() + }), + _ => { + let mut raw = first.to_uppercase(); + if !args.is_empty() { + raw.push(' '); + raw.push_str(args); + } + return self.send_raw(&raw); + } + } + Ok(()) + } + + pub fn join(&self, room: &str) -> crate::Result<()> { + let room = room.trim(); + if room.is_empty() { + return Err("room is required".into()); + } + self.send("JOIN", &[room]) + } + + pub fn part(&self, room: &str) -> crate::Result<()> { + let room = room.trim(); + if room.is_empty() { + return Err("room is required".into()); + } + self.send("PART", &[room]) + } + + /// Requests the server's room list; results arrive as a `room-list` event. + pub fn list_rooms(&self) -> crate::Result<()> { + self.st().list_rooms.clear(); + self.send("LIST", &[]) + } + + /// Currently joined rooms, sorted case-insensitively. + pub fn rooms(&self) -> Vec { + let st = self.st(); + let mut rooms: Vec = st + .room_names + .iter() + .map(|name| match st.channels.get(&fold(name)) { + Some(ch) => RoomInfo { name: ch.name.clone(), topic: ch.topic.clone(), users: ch.users.len() as i64 }, + None => RoomInfo { name: name.clone(), ..Default::default() }, + }) + .collect(); + rooms.sort_by_key(|r| r.name.to_lowercase()); + rooms + } + + /// Known users in a room, sorted ignoring mode prefixes. + pub fn users(&self, room: &str) -> Vec { + let st = self.st(); + let Some(ch) = st.channels.get(&fold(room)) else { return Vec::new() }; + let mut users = ch.users.clone(); + users.sort_by_key(|u| u.trim_start_matches(['~', '&', '@', '%', '+']).to_lowercase()); + users + } + + // ---- I/O thread ---- + + fn io_thread(self: Arc, out_rx: Receiver) { + let result = self.open_transport().and_then(|stream| self.run(stream, &out_rx)); + *self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; + let (was_closing, was_connected, ready) = { + let mut st = self.st(); + let r = (st.closing, st.connected, st.ready.take()); + 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 was_connected { + (self.cfg.on_close)(); + } + } + + fn open_transport(&self) -> crate::Result> { + let stream: Box = match &self.cfg.connector { + 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}"))?; + tcp.set_read_timeout(Some(READ_POLL))?; + tcp.set_write_timeout(Some(self.cfg.connect_timeout))?; + let _ = tcp.set_nodelay(true); + Box::new(tcp) + } + }; + if !self.cfg.use_tls { + return Ok(stream); + } + self.tls_wrap(stream).map_err(|e| format!("IRC TLS handshake failed: {e}").into()) + } + + fn tls_wrap(&self, sock: Box) -> crate::Result> { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + let name = rustls::pki_types::ServerName::try_from(self.cfg.server.clone())?; + let conn = rustls::ClientConnection::new(Arc::new(config), name)?; + let mut tls = rustls::StreamOwned::new(conn, sock); + let deadline = Instant::now() + self.cfg.connect_timeout; + while tls.conn.is_handshaking() { + match tls.conn.complete_io(&mut tls.sock) { + Ok(_) => {} + Err(e) if is_poll_timeout(&e) && Instant::now() < deadline => {} + Err(e) => return Err(e.into()), + } + } + Ok(Box::new(tls)) + } + + /// Registers, then pumps reads, the outgoing queue and keepalive pings until the connection + /// ends. `Ok` means a deliberate QUIT. + fn run(&self, mut stream: Box, out_rx: &Receiver) -> crate::Result<()> { + if self.st().closing { + return Ok(()); + } + let write = |stream: &mut Box, line: &str| -> crate::Result<()> { + stream.write_all(line.as_bytes())?; + stream.write_all(b"\r\n")?; + Ok(stream.flush()?) + }; + if !self.cfg.server_pass.is_empty() { + write(&mut stream, &encode("PASS", &[&self.cfg.server_pass]))?; + } + write(&mut stream, &encode("NICK", &[&self.cfg.nick]))?; + write(&mut stream, &encode("USER", &[&self.cfg.user, "*", "*", &self.cfg.real_name]))?; + + let started = Instant::now(); + let mut last_ping = Instant::now(); + let mut ping_sent = false; + self.st().last_pong = Instant::now(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + while let Ok(out) = out_rx.try_recv() { + match out { + Out::Line(line) => write(&mut stream, &line)?, + Out::Quit(msg) => { + let _ = write(&mut stream, &encode("QUIT", &[&msg])); + return Ok(()); + } + } + } + + if started.elapsed() >= PING_GRACE && last_ping.elapsed() >= PING_DELAY { + 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(); + write(&mut stream, &encode("PING", &[&id.to_string()]))?; + last_ping = Instant::now(); + ping_sent = true; + } + + match stream.read(&mut chunk) { + Ok(0) => return Err("connection closed by server".into()), + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if is_poll_timeout(&e) || e.kind() == ErrorKind::Interrupted => continue, + Err(e) => return Err(e.into()), + } + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let raw: Vec = buf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&raw); + if let Some(msg) = parse_line(line.trim_end_matches(['\r', '\n'])) { + self.handle_message(&msg); + } + } + if buf.len() > MAX_LINE_BYTES { + return Err("server line too long".into()); + } + } + } + + // ---- server message handling (girc's state tracking, then Link's handlers) ---- + + fn is_me(&self, nick: &str) -> bool { + fold(nick) == fold(&self.st().nick) + } + + fn handle_message(&self, m: &Message) { + match m.command.as_str() { + "001" => self.handle_welcome(m), + "433" | "436" | "437" => { + let mut st = self.st(); + if !st.connected { + st.nick.push('_'); + let nick = st.nick.clone(); + drop(st); + let _ = self.send("NICK", &[&nick]); + } + } + "PING" => { + let _ = self.send("PONG", &[m.last()]); + } + "PONG" => self.st().last_pong = Instant::now(), + "PRIVMSG" => self.handle_privmsg(m), + "NOTICE" => self.handle_notice(m), + "JOIN" => self.handle_join(m), + "PART" => self.handle_part(m), + "KICK" => self.handle_kick(m), + "QUIT" => self.handle_quit(m), + "NICK" => self.handle_nick(m), + "TOPIC" | "332" => self.handle_topic(m), + "353" => self.handle_names(m), + "366" => { + if m.params.len() >= 2 { + self.emit_users(m.param(1)); + } + } + "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 mut st = self.st(); + if st.list_rooms.len() < MAX_ROOM_LIST_ENTRIES { + st.list_rooms.push(room); + } + } + } + "323" => { + let mut rooms = std::mem::take(&mut self.st().list_rooms); + rooms.sort_by(|a, b| b.users.cmp(&a.users).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + self.emit(Event { kind: "room-list".into(), rooms, ..Default::default() }); + } + "ERROR" => self.emit(Event { kind: "error".into(), text: m.last().into(), ..Default::default() }), + _ => {} + } + } + + fn handle_welcome(&self, m: &Message) { + let ready = { + let mut st = self.st(); + if !m.params.is_empty() { + st.nick = m.param(0).to_string(); + } + st.connected = true; + 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.identify_with_nickserv(); + if !self.cfg.auto_join_rooms.is_empty() { + let _ = self.send("JOIN", &[&self.cfg.auto_join_rooms.join(",")]); + } + self.emit_rooms(); + if let Some(ready) = ready { + let _ = ready.send(Ok(())); + } + } + + fn identify_with_nickserv(&self) { + if !self.cfg.nickserv_identify || self.cfg.nickserv_password.is_empty() { + return; + } + let body = if self.cfg.nickserv_account.is_empty() { + format!("IDENTIFY {}", self.cfg.nickserv_password) + } else { + format!("IDENTIFY {} {}", self.cfg.nickserv_account, self.cfg.nickserv_password) + }; + let _ = self.send("PRIVMSG", &["NickServ", &body]); + self.emit(Event { kind: "system".into(), text: "Sent NickServ identify request".into(), ..Default::default() }); + } + + fn handle_privmsg(&self, m: &Message) { + if m.params.len() < 2 || m.source.is_empty() { + return; + } + let mut target = m.param(0).to_string(); + if self.is_me(&target) { + target = m.source.clone(); + } + let mut kind = "message"; + let mut text = m.last().to_string(); + if let Some((ctcp, args)) = decode_ctcp(m) { + match ctcp.as_str() { + "ACTION" => { + kind = "action"; + text = args; + } + "VERSION" => { + let _ = self.send("NOTICE", &[&m.source, "\x01VERSION Nexus Terminal\x01"]); + } + "PING" => { + let body = if args.is_empty() { "\x01PING\x01".to_string() } else { format!("\x01PING {args}\x01") }; + let _ = self.send("NOTICE", &[&m.source, &body]); + } + _ => {} + } + } + self.emit(Event { kind: kind.into(), target, from: m.source.clone(), text, ..Default::default() }); + } + + fn handle_notice(&self, m: &Message) { + let mut target = SERVER_TARGET.to_string(); + if !m.params.is_empty() { + target = m.param(0).to_string(); + if target == "*" { + target = SERVER_TARGET.into(); + } + if self.is_me(&target) && !m.source.is_empty() { + target = m.source.clone(); + } + } + self.emit(Event { kind: "notice".into(), target, from: m.source.clone(), text: m.last().into(), ..Default::default() }); + } + + fn handle_join(&self, m: &Message) { + let room = m.param(0).to_string(); + if room.is_empty() { + return; + } + 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(), + }); + add_user(ch, &from); + } + if self.is_me(&from) { + self.remember_room_membership(&room, true); + } + self.emit(Event { kind: "join".into(), target: room.clone(), from: from.clone(), text: format!("{from} joined {room}"), ..Default::default() }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_part(&self, m: &Message) { + let room = m.param(0).to_string(); + if room.is_empty() { + return; + } + let from = m.source.clone(); + let me = self.is_me(&from); + if !from.is_empty() { + let mut st = self.st(); + if me { + st.channels.remove(&fold(&room)); + } else if let Some(ch) = st.channels.get_mut(&fold(&room)) { + remove_user(ch, &from); + } + } + let message = if m.params.len() > 1 { m.last().trim().to_string() } else { String::new() }; + if me { + self.remember_room_membership(&room, false); + } + self.emit(Event { kind: "part".into(), target: room.clone(), from, text: message, ..Default::default() }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_kick(&self, m: &Message) { + if m.params.len() < 2 { + return; + } + let (room, kicked) = (m.param(0).to_string(), m.param(1).to_string()); + let me = self.is_me(&kicked); + { + let mut st = self.st(); + if me { + st.channels.remove(&fold(&room)); + } else if let Some(ch) = st.channels.get_mut(&fold(&room)) { + remove_user(ch, &kicked); + } + } + if me { + self.remember_room_membership(&room, false); + } + self.emit(Event { + kind: "kick".into(), + target: room.clone(), + from: m.source.clone(), + command: kicked, + text: m.last().trim().into(), + ..Default::default() + }); + self.emit_rooms(); + self.emit_users(&room); + } + + fn handle_quit(&self, m: &Message) { + if !m.source.is_empty() && !self.is_me(&m.source) { + for ch in self.st().channels.values_mut() { + remove_user(ch, &m.source); + } + } + self.emit(Event { kind: "quit".into(), from: m.source.clone(), text: m.last().trim().into(), ..Default::default() }); + self.emit_users_for_all(); + } + + fn handle_nick(&self, m: &Message) { + let new_nick = m.last().to_string(); + if !m.source.is_empty() && !m.params.is_empty() { + let mut st = self.st(); + if fold(&m.source) == fold(&st.nick) { + st.nick = new_nick.clone(); + } + for ch in st.channels.values_mut() { + if remove_user(ch, &m.source) { + add_user(ch, &new_nick); + } + } + } + self.emit(Event { kind: "nick".into(), from: m.source.clone(), text: new_nick, ..Default::default() }); + self.emit_users_for_all(); + } + + fn handle_topic(&self, m: &Message) { + let (room, topic) = if m.command == "332" && m.params.len() >= 3 { + (m.param(1), m.param(2)) + } else if m.params.len() >= 2 { + (m.param(0), m.param(1)) + } else { + return; + }; + if room.is_empty() { + return; + } + 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_rooms(); + } + + fn handle_names(&self, m: &Message) { + if m.params.len() < 4 { + return; + } + { + let mut st = self.st(); + if let Some(ch) = st.channels.get_mut(&fold(m.param(2))) { + for part in m.last().split(' ') { + let nick = part.trim_start_matches(['~', '&', '@', '%', '+']); + let nick = if nick.contains('@') { source_name(nick) } else { nick }; + if is_valid_nick(nick) { + add_user(ch, nick); + } + } + } + } + self.emit(Event { + kind: "users".into(), + target: m.param(2).into(), + users: m.param(3).split_whitespace().map(String::from).collect(), + ..Default::default() + }); + } + + // ---- emitting ---- + + fn remember_room_membership(&self, room: &str, joined: bool) { + let room = room.trim(); + if room.is_empty() { + return; + } + let names = { + let mut st = self.st(); + let position = st.room_names.iter().position(|r| r.eq_ignore_ascii_case(room)); + match (joined, position) { + (true, None) => st.room_names.push(room.to_string()), + (false, Some(i)) => { + st.room_names.remove(i); + } + _ => return, + } + st.room_names.clone() + }; + (self.cfg.on_rooms_changed)(names); + } + + fn emit_rooms(&self) { + self.emit(Event { kind: "rooms".into(), rooms: self.rooms(), ..Default::default() }); + } + + fn emit_users(&self, room: &str) { + if !room.is_empty() { + self.emit(Event { kind: "users".into(), target: room.into(), users: self.users(room), ..Default::default() }); + } + } + + fn emit_users_for_all(&self) { + for room in self.rooms() { + self.emit_users(&room.name); + } + } + + fn emit_local_message(&self, kind: &str, target: &str, text: &str) { + self.emit(Event { kind: kind.into(), target: target.into(), from: self.nick(), text: text.into(), ..Default::default() }); + } + + fn emit(&self, mut event: Event) { + if event.timestamp == 0 { + event.timestamp = chrono::Utc::now().timestamp_millis(); + } + if event.target.is_empty() { + event.target = SERVER_TARGET.into(); + } + (self.cfg.on_event)(event); + } +} + +fn dial(host: &str, port: u16, timeout: Duration) -> std::io::Result { + let mut last = std::io::Error::new(ErrorKind::NotFound, "no addresses found"); + for addr in (host, port).to_socket_addrs()? { + match TcpStream::connect_timeout(&addr, timeout) { + Ok(s) => return Ok(s), + Err(e) => last = e, + } + } + Err(last) +} + +fn is_poll_timeout(e: &std::io::Error) -> bool { + matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) +} + +/// girc's `ToRFC1459`: ASCII A-Z and `[\]^` fold to a-z and `{|}~`. +fn fold(s: &str) -> String { + s.chars().map(|c| if ('A'..='^').contains(&c) { (c as u8 + 32) as char } else { c }).collect() +} + +fn add_user(ch: &mut Channel, nick: &str) { + if !ch.users.iter().any(|u| fold(u) == fold(nick)) { + ch.users.push(nick.to_string()); + } +} + +fn remove_user(ch: &mut Channel, nick: &str) -> bool { + let before = ch.users.len(); + ch.users.retain(|u| fold(u) != fold(nick)); + ch.users.len() != before +} + +/// girc's `IsValidNick`. +fn is_valid_nick(nick: &str) -> bool { + let b = nick.as_bytes(); + let Some(&first) = b.first() else { return false }; + if !(b'A'..=b'}').contains(&first) && first != b'?' { + return false; + } + b[1..].iter().all(|&c| (b'A'..=b'}').contains(&c) || c.is_ascii_digit() || c == b'-') +} + +/// The nick part of `nick!user@host`. +fn source_name(prefix: &str) -> &str { + prefix.split(['!', '@']).next().unwrap_or("") +} + +/// Encodes a command line the way girc does: the last parameter gets a `:` when it is empty, +/// contains a space or starts with `:`; CR/LF are stripped. +fn encode(command: &str, params: &[&str]) -> String { + let mut line = command.to_string(); + for (i, p) in params.iter().enumerate() { + line.push(' '); + if i == params.len() - 1 && (p.is_empty() || p.contains(' ') || p.starts_with(':')) { + line.push(':'); + } + line.push_str(p); + } + line.retain(|c| c != '\r' && c != '\n'); + line +} + +fn parse_line(line: &str) -> Option { + let mut rest = line.trim_start(); + if rest.starts_with('@') { + rest = rest.split_once(' ')?.1.trim_start(); + } + let mut source = String::new(); + if let Some(prefixed) = rest.strip_prefix(':') { + let (prefix, tail) = prefixed.split_once(' ')?; + source = source_name(prefix).to_string(); + rest = tail.trim_start(); + } + let (head, trailing) = match rest.split_once(" :") { + Some((h, t)) => (h, Some(t)), + None => (rest, None), + }; + let mut words = head.split(' ').filter(|w| !w.is_empty()); + let command = words.next()?.to_uppercase(); + let mut params: Vec = words.map(String::from).collect(); + params.extend(trailing.map(String::from)); + Some(Message { source, command, params }) +} + +/// A CTCP request in a PRIVMSG: `(TAG, args)`, as girc's `DecodeCTCP`. +fn decode_ctcp(m: &Message) -> Option<(String, String)> { + if m.params.len() != 2 { + return None; + } + let text = m.param(1).strip_prefix('\x01')?.strip_suffix('\x01')?; + let (tag, args) = text.split_once(' ').unwrap_or((text, "")); + if tag.is_empty() || !tag.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) { + return None; + } + Some((tag.to_string(), args.to_string())) +} + +/// The first whitespace-separated word, and the trimmed remainder. +fn split_first_arg(input: &str) -> (&str, &str) { + let input = input.trim_start(); + match input.split_whitespace().next() { + Some(first) => (first, input[first.len()..].trim()), + None => ("", ""), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + + #[test] + 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() + }); + client.remember_room_membership("#One", true); + client.remember_room_membership("#two", true); + client.remember_room_membership("#one", true); + client.remember_room_membership("#missing", false); + client.remember_room_membership("#ONE", false); + assert_eq!(updates.lock().unwrap().join("|"), "#One|#One,#two|#two"); + assert_eq!(client.st().room_names, vec!["#two"]); + } + + #[test] + fn parses_and_encodes_lines() { + let m = parse_line("@time=x :nick!u@h PRIVMSG #chan :hello there").unwrap(); + assert_eq!((m.source.as_str(), m.command.as_str()), ("nick", "PRIVMSG")); + assert_eq!(m.params, vec!["#chan", "hello there"]); + let m = parse_line("PING :abc").unwrap(); + assert_eq!((m.source.as_str(), m.last()), ("", "abc")); + let m = parse_line(":srv 353 me = #c :@op +v plain").unwrap(); + assert_eq!(m.params.len(), 4); + assert_eq!(encode("PRIVMSG", &["#c", "hi"]), "PRIVMSG #c hi"); + assert_eq!(encode("PRIVMSG", &["#c", "hi there"]), "PRIVMSG #c :hi there"); + assert_eq!(encode("TOPIC", &["#c", ""]), "TOPIC #c :"); + assert_eq!(encode("QUIT", &[":x\r\nJOIN #evil"]), "QUIT ::xJOIN #evil"); + assert_eq!(split_first_arg(" #room key here "), ("#room", "key here")); + assert_eq!(split_first_arg(" "), ("", "")); + assert_eq!(fold("Nick[A]^"), "nick{a}~"); + } + + #[test] + 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() + }; + assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"rooms","timestamp":0,"rooms":[{"name":"#a"}]}"##); + } + + struct Fake { + reader: BufReader, + writer: TcpStream, + } + + impl Fake { + fn line(&mut self) -> String { + let mut s = String::new(); + self.reader.read_line(&mut s).unwrap(); + s.trim_end().to_string() + } + fn expect(&mut self, want: &str) { + for _ in 0..20 { + if self.line() == want { + return; + } + } + panic!("never received {want:?}"); + } + fn send(&mut self, line: &str) { + self.writer.write_all(format!("{line}\r\n").as_bytes()).unwrap(); + } + } + + fn harness(cfg: Config) -> (Arc, TcpListener, Receiver, Arc>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let (tx, rx) = mpsc::channel(); + let tx = Mutex::new(tx); + let closes = Arc::new(Mutex::new(0)); + let c = closes.clone(); + let client = Client::new(Config { + server: "127.0.0.1".into(), + port: listener.local_addr().unwrap().port(), + nick: "me".into(), + connect_timeout: Duration::from_secs(5), + on_event: Arc::new(move |e| { + let _ = tx.lock().unwrap().send(e); + }), + on_close: Arc::new(move || *c.lock().unwrap() += 1), + ..cfg + }); + (client, listener, rx, closes) + } + + fn accept(listener: &TcpListener) -> Fake { + let (s, _) = listener.accept().unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + Fake { reader: BufReader::new(s.try_clone().unwrap()), writer: s } + } + + fn wait_for(rx: &Receiver, pred: impl Fn(&Event) -> bool) -> Event { + loop { + let e = rx.recv_timeout(Duration::from_secs(5)).expect("event"); + if pred(&e) { + return e; + } + } + } + + #[test] + fn registers_tracks_rooms_and_handles_input() { + let (client, listener, rx, closes) = harness(Config { + server_pass: "pw".into(), + auto_join_rooms: vec!["#a".into(), "#b".into()], + nickserv_identify: true, + nickserv_account: "acct".into(), + nickserv_password: "secret".into(), + ..Default::default() + }); + let c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + assert_eq!(srv.line(), "PASS pw"); + assert_eq!(srv.line(), "NICK me"); + assert_eq!(srv.line(), "USER me * * me"); + srv.send(":srv 433 * me :Nickname is already in use"); + assert_eq!(srv.line(), "NICK me_"); + srv.send(":srv 001 me_ :Welcome"); + connecting.join().unwrap().unwrap(); + let e = wait_for(&rx, |e| e.kind == "system"); + assert_eq!(e.text, format!("Connected to 127.0.0.1:{}", client.cfg.port)); + assert_eq!(e.target, SERVER_TARGET); + assert_eq!(srv.line(), "PRIVMSG NickServ :IDENTIFY acct secret"); + assert_eq!(srv.line(), "JOIN #a,#b"); + assert_eq!(wait_for(&rx, |e| e.kind == "system").text, "Sent NickServ identify request"); + + srv.send("PING :xyz"); + srv.expect("PONG xyz"); + + srv.send(":me_!u@h JOIN #a"); + let e = wait_for(&rx, |e| e.kind == "join"); + assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "me_", "me_ joined #a")); + srv.send(":srv 332 me_ #a :the topic"); + assert_eq!(wait_for(&rx, |e| e.kind == "topic").topic, "the topic"); + srv.send(":srv 353 me_ = #a :@me_ +Bob carol"); + assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["@me_", "+Bob", "carol"]); + srv.send(":srv 366 me_ #a :End of NAMES"); + assert_eq!(wait_for(&rx, |e| e.kind == "users").users, vec!["Bob", "carol", "me_"]); + assert_eq!(client.rooms(), vec![RoomInfo { name: "#a".into(), topic: "the topic".into(), users: 3 }]); + + srv.send(":Bob!u@h PRIVMSG #a :hi all"); + let e = wait_for(&rx, |e| e.kind == "message"); + assert_eq!((e.target.as_str(), e.from.as_str(), e.text.as_str()), ("#a", "Bob", "hi all")); + srv.send(":Bob!u@h PRIVMSG ME_ :\x01ACTION waves\x01"); + let e = wait_for(&rx, |e| e.kind == "action"); + assert_eq!((e.target.as_str(), e.text.as_str()), ("Bob", "waves")); + srv.send(":Bob!u@h NICK :Robert"); + assert_eq!(wait_for(&rx, |e| e.kind == "nick").text, "Robert"); + assert_eq!(client.users("#A"), vec!["carol", "me_", "Robert"]); + srv.send(":carol!u@h QUIT :bye"); + assert_eq!(wait_for(&rx, |e| e.kind == "quit").text, "bye"); + assert_eq!(client.users("#a"), vec!["me_", "Robert"]); + srv.send(":Robert!u@h KICK #a me_ :out"); + let e = wait_for(&rx, |e| e.kind == "kick"); + assert_eq!((e.from.as_str(), e.command.as_str(), e.text.as_str()), ("Robert", "me_", "out")); + assert!(client.rooms().is_empty()); + + client.handle_input("#a", "hello there").unwrap(); + srv.expect("PRIVMSG #a :hello there"); + let e = wait_for(&rx, |e| e.kind == "message"); + assert_eq!((e.from.as_str(), e.text.as_str()), ("me_", "hello there")); + client.handle_input("#a", "/me dances").unwrap(); + srv.expect("PRIVMSG #a :\x01ACTION dances\x01"); + client.handle_input("#a", "/join #c key").unwrap(); + srv.expect("JOIN #c key"); + client.handle_input("#a", "/part").unwrap(); + srv.expect("PART #a"); + client.handle_input("#a", "/topic").unwrap(); + srv.expect("TOPIC #a"); + client.handle_input("#a", "/names").unwrap(); + 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 " + ); + 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")); + + client.list_rooms().unwrap(); + srv.expect("LIST"); + srv.send(":srv 322 me_ #small 2 :s"); + srv.send(":srv 322 me_ #Big 9 :b"); + srv.send(":srv 322 me_ #also 2 :a"); + srv.send(":srv 323 me_ :End"); + let names: Vec<_> = wait_for(&rx, |e| e.kind == "room-list").rooms.into_iter().map(|r| r.name).collect(); + assert_eq!(names, vec!["#Big", "#also", "#small"]); + + client.close(); + client.close(); + srv.expect("QUIT :Nexus Terminal disconnected"); + let deadline = Instant::now() + Duration::from_secs(5); + while *closes.lock().unwrap() == 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(*closes.lock().unwrap(), 1); + assert!(client.handle_input("#a", "late").is_err()); + } + + #[test] + 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 c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + srv.expect("USER me * * me"); + srv.send(":srv 001 me :Welcome"); + connecting.join().unwrap().unwrap(); + srv.send("ERROR :Closing link"); + assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "Closing link"); + drop(srv); + assert_eq!(wait_for(&rx, |e| e.kind == "error").text, "connection closed by server"); + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(*errors.lock().unwrap(), vec!["connection closed by server"]); + assert_eq!(*closes.lock().unwrap(), 1); + } + + #[test] + fn connect_times_out_without_welcome() { + let (client, listener, _rx, closes) = harness(Config::default()); + let client = Client::new(Config { connect_timeout: Duration::from_millis(300), ..client.cfg.clone() }); + let err = client.connect().unwrap_err().to_string(); + assert_eq!(err, "IRC connection timed out after 300ms"); + drop(listener); + assert_eq!(*closes.lock().unwrap(), 0); + } + + #[test] + fn connector_supplies_the_transport() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let used = Arc::new(Mutex::new(false)); + let flag = used.clone(); + let client = Client::new(Config { + server: "unresolvable.invalid".into(), + nick: "me".into(), + connect_timeout: Duration::from_secs(5), + connector: Some(Arc::new(move || { + *flag.lock().unwrap() = true; + let s = TcpStream::connect(addr)?; + s.set_read_timeout(Some(Duration::from_millis(20)))?; + Ok(Box::new(s) as Box) + })), + ..Default::default() + }); + let c = client.clone(); + let connecting = std::thread::spawn(move || c.connect()); + let mut srv = accept(&listener); + srv.expect("USER me * * me"); + srv.send(":srv 001 me :Welcome"); + connecting.join().unwrap().unwrap(); + assert!(*used.lock().unwrap()); + client.handle_input("", "/quit gone").unwrap(); + srv.expect("QUIT gone"); + } + + #[test] + fn dial_failure_is_reported() { + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let client = Client::new(Config { server: "127.0.0.1".into(), port, nick: "me".into(), ..Default::default() }); + let err = client.connect().unwrap_err().to_string(); + assert!(err.starts_with(&format!("dial tcp 127.0.0.1:{port}:")), "{err}"); + } +} diff --git a/rust/core/src/clients/mod.rs b/rust/core/src/clients/mod.rs index 4442451..69f0f2b 100644 --- a/rust/core/src/clients/mod.rs +++ b/rust/core/src/clients/mod.rs @@ -6,6 +6,8 @@ //! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not //! fire `on_close` re-entrantly while holding locks. +pub mod github; +pub mod irc; pub mod local; pub mod sftp; pub mod ssh; diff --git a/rust/core/src/ircformat.rs b/rust/core/src/ircformat.rs new file mode 100644 index 0000000..115888e --- /dev/null +++ b/rust/core/src/ircformat.rs @@ -0,0 +1,469 @@ +//! mIRC formatting (^B ^] ^^ ^_ ^V ^Q ^O ^C fg,bg ^D hex) to styled spans, the nick colour hash +//! and the IRC event line layout. Ported from the Wails frontend's `irc.js`. +//! +//! Colours are xterm-256 palette indices: IRC colour codes are mapped through `IRC_COLOURS`. + +use crate::clients::irc::Event; + +/// IRC colours 00-98 mapped to the xterm 256-colour palette (modern IRC formatting spec). +/// Colour 99, and anything out of range, means the terminal default. +pub const IRC_COLOURS: [u8; 99] = [ + 15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7, // + 52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89, // + 88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125, // + 124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161, // + 196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198, // + 203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205, // + 217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212, // + 16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231, +]; + +/// Palette indices used for nicks. +pub const NICK_COLOURS: [u8; 12] = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220]; + +const GREY: u8 = 244; +const RED: u8 = 203; +const GREEN: u8 = 83; +const YELLOW: u8 = 221; +const CYAN: u8 = 81; +const MAGENTA: u8 = 177; + +/// Text style. `fg`/`bg` are xterm-256 indices; `fg_rgb`/`bg_rgb` come from ^D hex colours. +/// At most one of `fg`/`fg_rgb` (and of `bg`/`bg_rgb`) is set. `None` is the default colour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub fg_rgb: Option<[u8; 3]>, + pub bg_rgb: Option<[u8; 3]>, + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub reverse: bool, + pub strike: bool, + pub monospace: bool, +} + +impl Style { + fn fg(colour: u8) -> Style { + Style { fg: Some(colour), ..Style::default() } + } + fn set_fg(&mut self, colour: u8) { + self.fg = Some(colour); + self.fg_rgb = None; + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Span { + pub text: String, + pub style: Style, +} + +/// Spans under construction; `style` applies to the next text pushed. +#[derive(Default)] +struct Line { + spans: Vec, + style: Style, +} + +impl Line { + fn push(&mut self, text: &str) { + if text.is_empty() { + return; + } + match self.spans.last_mut() { + Some(last) if last.style == self.style => last.text.push_str(text), + _ => self.spans.push(Span { text: text.to_string(), style: self.style }), + } + } + + /// `text` in `style`, then back to the default style. + fn styled(&mut self, style: Style, text: &str) { + self.style = style; + self.push(text); + self.style = Style::default(); + } + + /// A bold, nick-coloured name layered on the current style, then reset. + fn nick(&mut self, nick: &str) { + let mut style = self.style; + style.bold = true; + style.set_fg(nick_colour(nick)); + self.styled(style, nick); + } + + /// Applies mIRC formatting codes in `value` on top of the current style. + fn mirc(&mut self, value: &str) { + let input: Vec = strip_terminal_escapes(value).chars().collect(); + // Toggle states start off regardless of the inherited style, as in irc.js. + let (mut bold, mut italic, mut strike, mut underline, mut reverse, mut mono) = (false, false, false, false, false, false); + let mut i = 0; + while i < input.len() { + let c = input[i]; + i += 1; + match c { + '\x02' => { + bold = !bold; + self.style.bold = bold; + if !bold { + self.style.dim = false; // SGR 22 clears both + } + } + '\x1d' => { + italic = !italic; + self.style.italic = italic; + } + '\x1e' => { + strike = !strike; + self.style.strike = strike; + } + '\x1f' => { + underline = !underline; + self.style.underline = underline; + } + '\x16' => { + reverse = !reverse; + self.style.reverse = reverse; + } + '\x11' => { + mono = !mono; + self.style.monospace = mono; + } + '\x0f' => { + self.style = Style::default(); + (bold, italic, strike, underline, reverse, mono) = (false, false, false, false, false, false); + } + '\x03' | '\x04' => { + let hex = c == '\x04'; + let read = |at: usize| if hex { read_hex(&input, at) } else { read_decimal(&input, at) }; + let Some((fg, len)) = read(i) else { + self.reset_colours(); + continue; + }; + i += len; + self.apply_colour(fg, false); + if input.get(i) != Some(&',') { + continue; + } + match read(i + 1) { + Some((bg, len)) => { + i += len + 1; + self.apply_colour(bg, true); + } + None => { + self.push(","); + i += 1; + } + } + } + c if (c as u32) < 0x20 || c as u32 == 0x7f => {} + c => self.push(c.encode_utf8(&mut [0; 4])), + } + } + } + + fn reset_colours(&mut self) { + (self.style.fg, self.style.bg, self.style.fg_rgb, self.style.bg_rgb) = (None, None, None, None); + } + + fn apply_colour(&mut self, colour: Colour, background: bool) { + let s = &mut self.style; + let (index, rgb) = if background { (&mut s.bg, &mut s.bg_rgb) } else { (&mut s.fg, &mut s.fg_rgb) }; + match colour { + Colour::Indexed(code) => { + *index = IRC_COLOURS.get(code as usize).copied(); + *rgb = None; + } + Colour::Rgb(value) => { + *rgb = Some(value); + *index = None; + } + } + } +} + +enum Colour { + Indexed(u8), + Rgb([u8; 3]), +} + +fn read_decimal(input: &[char], at: usize) -> Option<(Colour, usize)> { + let digits: String = input.iter().skip(at).take(2).take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + Some((Colour::Indexed(digits.parse().ok()?), digits.len())) +} + +fn read_hex(input: &[char], at: usize) -> Option<(Colour, usize)> { + let hex: String = input.get(at..at + 6)?.iter().collect(); + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + Some((Colour::Rgb([byte(0)?, byte(2)?, byte(4)?]), 6)) +} + +/// Removes untrusted terminal sequences (OSC, CSI, other ESC pairs) and C1 controls, applying the +/// same four passes as irc.js. +fn strip_terminal_escapes(value: &str) -> String { + // OSC: ESC ] ... (BEL | ESC \), greedy like the JS regex. + let mut s = String::new(); + let mut rest = value; + while let Some(pos) = rest.find("\x1b]") { + let body = &rest[pos + 2..]; + let run_end = body.find('\x07'); + let end = match run_end { + Some(bel) => Some(bel + 1), + None => body.rfind("\x1b\\").map(|st| st + 2), + }; + match end { + Some(end) => { + s.push_str(&rest[..pos]); + rest = &body[end..]; + } + None => { + s.push_str(&rest[..pos + 2]); + rest = body; + } + } + } + s.push_str(rest); + + // CSI: ESC [ params intermediates final. + let chars: Vec = s.chars().collect(); + let mut csi = String::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '\x1b' && chars.get(i + 1) == Some(&'[') { + let mut j = i + 2; + while chars.get(j).is_some_and(|c| ('0'..='?').contains(c)) { + j += 1; + } + while chars.get(j).is_some_and(|c| (' '..='/').contains(c)) { + j += 1; + } + if chars.get(j).is_some_and(|c| ('@'..='~').contains(c)) { + i = j + 1; + continue; + } + } + csi.push(chars[i]); + i += 1; + } + + // ESC plus any character but a line terminator. + let chars: Vec = csi.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '\x1b' && chars.get(i + 1).is_some_and(|c| !matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')) { + i += 2; + continue; + } + out.push(chars[i]); + i += 1; + } + out.retain(|c| !('\u{80}'..='\u{9f}').contains(&c)); + out +} + +/// mIRC-formatted text as styled spans, starting from the default style. +pub fn format_text(value: &str) -> Vec { + let mut line = Line::default(); + line.mirc(value); + line.spans +} + +/// The text with all formatting removed. +pub fn plain_text(value: &str) -> String { + format_text(value).into_iter().map(|s| s.text).collect() +} + +/// The palette index for a nick (Java-style string hash over code points). +pub fn nick_colour(nick: &str) -> u8 { + let hash = nick.chars().fold(0u32, |h, c| h.wrapping_mul(31).wrapping_add(c as u32)); + NICK_COLOURS[hash as usize % NICK_COLOURS.len()] +} + +/// One chat line for an event: `[time] body`, laid out per event type as the Wails viewer did. +pub fn format_event(event: &Event, time: &str) -> Vec { + let from = plain_text(&event.from); + let subject = plain_text(&event.command); + let mut l = Line::default(); + l.styled(Style { dim: true, ..Style::fg(GREY) }, &format!("[{time}]")); + l.push(" "); + + let nick_or = |l: &mut Line, fallback: &str| { + if from.is_empty() { l.push(fallback) } else { l.nick(&from) } + }; + let suffix = |l: &mut Line| { + if !event.text.is_empty() { + l.push(" ("); + l.mirc(&event.text); + l.push(")"); + } + }; + let magenta_italic = Style { italic: true, ..Style::fg(MAGENTA) }; + + match event.kind.as_str() { + "message" => { + l.styled(Style::fg(GREY), "<"); + nick_or(&mut l, ""); + l.styled(Style::fg(GREY), ">"); + l.push(" "); + l.mirc(&event.text); + } + "action" => { + l.style = magenta_italic; + l.push("* "); + nick_or(&mut l, ""); + l.style = magenta_italic; + l.push(" "); + l.mirc(&event.text); + } + "notice" => { + l.style = Style::fg(YELLOW); + l.push("-"); + nick_or(&mut l, "notice"); + l.styled(Style::fg(YELLOW), "-"); + l.push(" "); + l.mirc(&event.text); + } + "join" => { + l.styled(Style::fg(GREEN), "-->"); + l.push(" "); + nick_or(&mut l, "user"); + l.push(" joined"); + } + "part" | "quit" => { + l.styled(Style::fg(RED), "<--"); + l.push(" "); + nick_or(&mut l, "user"); + l.push(if event.kind == "part" { " left" } else { " quit" }); + suffix(&mut l); + } + "kick" => { + l.styled(Style::fg(RED), "<--"); + l.push(" "); + l.styled(Style { bold: true, ..Style::default() }, if subject.is_empty() { "user" } else { &subject }); + l.push(" was kicked by "); + nick_or(&mut l, "server"); + suffix(&mut l); + } + "nick" => { + nick_or(&mut l, "user"); + l.push(" is now known as "); + l.styled(Style { bold: true, ..Style::default() }, &plain_text(&event.text)); + } + "topic" | "error" => { + let (colour, label) = if event.kind == "topic" { (CYAN, "topic:") } else { (RED, "error:") }; + l.styled(Style { bold: true, ..Style::fg(colour) }, label); + l.push(" "); + l.mirc(&event.text); + } + _ => { + l.style = Style::fg(CYAN); + l.mirc(&event.text); + } + } + l.spans +} + +#[cfg(test)] +mod tests { + use super::*; + + fn span(text: &str, style: Style) -> Span { + Span { text: text.into(), style } + } + + #[test] + fn toggles() { + let b = Style { bold: true, ..Style::default() }; + let i = Style { italic: true, ..Style::default() }; + let u = Style { underline: true, ..Style::default() }; + let d = Style::default(); + assert_eq!( + format_text("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"), + vec![span("plain ", d), span("bold", b), span(" ", d), span("italic", i), span(" ", d), span("underlined", u)] + ); + let all = format_text("\x16\x1e\x11x\x0fy"); + assert_eq!(all[0].style, Style { reverse: true, strike: true, monospace: true, ..Style::default() }); + assert_eq!(all[1], span("y", d)); + } + + #[test] + fn indexed_colours() { + assert_eq!(format_text("\x0304red\x03 default"), vec![span("red", Style::fg(9)), span(" default", Style::default())]); + assert_eq!( + format_text("\x0352extended \x0303,01matrix"), + vec![span("extended ", Style::fg(196)), span("matrix", Style { bg: Some(0), ..Style::fg(2) })] + ); + assert_eq!(format_text("\x0399default"), vec![span("default", Style::default())]); + } + + #[test] + 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()) + ] + ); + 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() }); + } + + #[test] + fn colour_without_background_keeps_comma() { + assert_eq!(format_text("\x0304,comma"), vec![span(",comma", Style::fg(9))]); + } + + #[test] + fn plain_text_strips_formatting() { + assert_eq!(plain_text("\x02bold\x02 \x0304red\x03"), "bold red"); + } + + #[test] + fn untrusted_terminal_sequences_are_removed() { + assert_eq!(plain_text("before\x1b[31munsafe\x1b]0;title\x07after"), "beforeunsafeafter"); + assert_eq!(plain_text("a\x1b]8;;x\x1b\\b\u{9b}c\x1bZd\x01"), "abcd"); + } + + #[test] + fn nick_colour_matches_js_hash() { + // Hashes computed with the irc.js function under node. + assert_eq!(nick_colour(""), 33); + assert_eq!(nick_colour("bob"), NICK_COLOURS[97717 % 12]); + assert_eq!(nick_colour("alice"), NICK_COLOURS[92903040 % 12]); + } + + #[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", + ); + 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(); + assert_eq!(nick.style, Style { bold: true, ..Style::fg(nick_colour("alice")) }); + let text: String = message.iter().map(|s| s.text.as_str()).collect(); + assert_eq!(text, "[12:34:56] hello world"); + + 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", + ); + assert_eq!(kick.iter().map(|s| s.text.as_str()).collect::(), "[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))); + } +} diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index 59c0dd7..4d5c119 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -4,6 +4,7 @@ pub mod clients; pub mod config; pub mod exportimport; +pub mod ircformat; pub mod keyfiles; pub mod models; pub mod recording;