From 285a2049cc93216041f72c8a889c1d0da61a0421 Mon Sep 17 00:00:00 2001 From: rain-bus Date: Wed, 3 Jun 2026 21:06:13 +0800 Subject: [PATCH] feat: add latency probing, platform installers (deb/rpm/pkg/dmg/exe) --- .github/workflows/release.yml | 78 +++++++++++++++++++++++++++++++++-- nfpm.yaml | 29 +++++++++++++ src/app.rs | 1 + src/app/latency.rs | 44 ++++++++++++++++++++ src/app/types.rs | 3 ++ src/ui/app.rs | 29 +++++++++++++ src/ui/view/home_list.rs | 41 +++++++++++------- sshell.iss | 61 +++++++++++++++++++++++++++ 8 files changed, 268 insertions(+), 18 deletions(-) create mode 100644 nfpm.yaml create mode 100644 src/app/latency.rs create mode 100644 sshell.iss diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f8878e..343556c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,11 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu artifact: sshell-x86_64-linux + nfpm_arch: amd64 - os: ubuntu-latest target: aarch64-unknown-linux-gnu artifact: sshell-aarch64-linux + nfpm_arch: arm64 # macOS - os: macos-latest target: aarch64-apple-darwin @@ -35,10 +37,10 @@ jobs: # Windows - os: windows-latest target: x86_64-pc-windows-msvc - artifact: sshell-x86_64-windows.exe + artifact: sshell-x86_64-windows - os: windows-latest target: aarch64-pc-windows-msvc - artifact: sshell-aarch64-windows.exe + artifact: sshell-aarch64-windows steps: - uses: actions/checkout@v4 @@ -60,19 +62,77 @@ jobs: - name: Build run: cargo build --release --target ${{ matrix.target }} - - name: Package (Unix) + - name: Set version from tag + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "SHELL_VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Package binary archive (Unix) if: runner.os != 'Windows' run: | cp target/${{ matrix.target }}/release/sshell sshell chmod +x sshell tar czf ${{ matrix.artifact }}.tar.gz sshell - - name: Package (Windows) + - name: Package binary archive (Windows) if: runner.os == 'Windows' run: | copy target\${{ matrix.target }}\release\sshell.exe sshell.exe Compress-Archive -Path sshell.exe -DestinationPath ${{ matrix.artifact }}.zip + # --- Linux installer packages (nfpm) --- + - name: Install nfpm + if: runner.os == 'Linux' + uses: jaxxstorm/action-install-gh-release@v1.14.0 + with: + repo: goreleaser/nfpm/v2 + cache: enable + + - name: Package .deb + if: runner.os == 'Linux' + env: + NFPM_ARCH: ${{ matrix.nfpm_arch }} + NFPM_VERSION: ${{ env.SHELL_VERSION }} + run: nfpm pkg --packager deb --target ${{ matrix.artifact }}.deb + + - name: Package .rpm + if: runner.os == 'Linux' + env: + NFPM_ARCH: ${{ matrix.nfpm_arch }} + NFPM_VERSION: ${{ env.SHELL_VERSION }} + run: nfpm pkg --packager rpm --target ${{ matrix.artifact }}.rpm + + - name: Package .pkg.tar.zst + if: runner.os == 'Linux' + env: + NFPM_ARCH: ${{ matrix.nfpm_arch }} + NFPM_VERSION: ${{ env.SHELL_VERSION }} + run: nfpm pkg --packager archlinux --target ${{ matrix.artifact }}.pkg.tar.zst + + # --- macOS DMG --- + - name: Package .dmg + if: runner.os == 'macOS' + run: | + STAGING_DIR=$(mktemp -d) + cp sshell "$STAGING_DIR/sshell" + chmod +x "$STAGING_DIR/sshell" + hdiutil create -volname "sshell" -srcfolder "$STAGING_DIR" -ov -format UDZO ${{ matrix.artifact }}.dmg + rm -rf "$STAGING_DIR" + + # --- Windows installer (Inno Setup) --- + - name: Compile Inno Setup installer + if: runner.os == 'Windows' + uses: Minionguyjpro/Inno-Setup-Action@v1.2.8 + env: + SHELL_VERSION: ${{ env.SHELL_VERSION }} + with: + path: sshell.iss + options: >- + /DMyArch=${{ matrix.target == 'x86_64-pc-windows-msvc' && 'x64compatible' || 'arm64' }} + /DMyArchAllowed=${{ matrix.target == 'x86_64-pc-windows-msvc' && 'x64compatible' || 'arm64' }} + /DMyArchInstall64=${{ matrix.target == 'x86_64-pc-windows-msvc' && 'x64compatible' || 'arm64' }} + - name: Upload artifact uses: actions/upload-artifact@v4 with: @@ -80,6 +140,11 @@ jobs: path: | ${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }}.zip + ${{ matrix.artifact }}.deb + ${{ matrix.artifact }}.rpm + ${{ matrix.artifact }}.pkg.tar.zst + ${{ matrix.artifact }}.dmg + sshell-*-windows-setup.exe release: name: Create GitHub Release @@ -100,3 +165,8 @@ jobs: files: | artifacts/*.tar.gz artifacts/*.zip + artifacts/*.deb + artifacts/*.rpm + artifacts/*.pkg.tar.zst + artifacts/*.dmg + artifacts/*-windows-setup.exe diff --git a/nfpm.yaml b/nfpm.yaml new file mode 100644 index 0000000..1952f1e --- /dev/null +++ b/nfpm.yaml @@ -0,0 +1,29 @@ +name: sshell +arch: ${NFPM_ARCH} +platform: linux +version: ${NFPM_VERSION} +section: utils +priority: optional +maintainer: rain-bus +description: | + A personal SSH and shell connection manager with a terminal user interface (TUI). + Manage SSH and local shell profiles with tagging, usage tracking, and smart sorting. +vendor: rain-bus +homepage: https://github.com/Rain-Bus/sshell +license: MIT + +contents: + - src: sshell + dst: /usr/bin/sshell + file_info: + mode: 0755 + +deb: + compression: gz + +rpm: + compression: gz + +archlinux: + pkgbase: sshell + packager: rain-bus diff --git a/src/app.rs b/src/app.rs index 0a2ee86..b5cb81e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ mod home_ops; +pub mod latency; mod profile_ext; mod types; diff --git a/src/app/latency.rs b/src/app/latency.rs new file mode 100644 index 0000000..984e505 --- /dev/null +++ b/src/app/latency.rs @@ -0,0 +1,44 @@ +use std::collections::HashMap; +use std::net::TcpStream; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Result of a single latency probe. +#[derive(Debug, Clone, Copy)] +pub enum LatencyStatus { + /// Host reachable, round-trip in milliseconds. + Reachable { ms: u64 }, + /// TCP connect failed or timed out. + Unreachable, + /// No latency check applicable (shell connections). + Local, + /// Not yet checked. + Unknown, +} + +/// Shared latency cache keyed by "host:port" strings. +/// Background threads write, draw reads. +pub type LatencyCache = Arc>>; + +/// Perform a TCP connect to measure latency. +/// Timeout is 3 seconds. Called from spawned threads. +pub fn probe(host: &str, port: u16) -> LatencyStatus { + let target = format!("{host}:{port}"); + let start = Instant::now(); + + let addr = match std::net::ToSocketAddrs::to_socket_addrs(&target) { + Ok(mut addrs) => match addrs.next() { + Some(a) => a, + None => return LatencyStatus::Unreachable, + }, + Err(_) => return LatencyStatus::Unreachable, + }; + + match TcpStream::connect_timeout(&addr, Duration::from_secs(3)) { + Ok(_) => { + let ms = start.elapsed().as_millis() as u64; + LatencyStatus::Reachable { ms } + } + Err(_) => LatencyStatus::Unreachable, + } +} diff --git a/src/app/types.rs b/src/app/types.rs index 1007316..f1a7ddc 100644 --- a/src/app/types.rs +++ b/src/app/types.rs @@ -2,6 +2,7 @@ use crate::import::ImportCandidate; use super::cred::CredFormState; use super::form::FormState; +use super::latency::LatencyCache; use super::settings::SettingsState; // ── Text editing trait ────────────────────────────────────── @@ -183,6 +184,7 @@ pub struct Session { pub toast: Option, pub should_quit: bool, pub settings: SettingsState, + pub latency: LatencyCache, } #[derive(Debug, Clone)] @@ -219,6 +221,7 @@ impl Session { toast: None, should_quit: false, settings: SettingsState::default(), + latency: LatencyCache::default(), } } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 9c1e0fb..a300803 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,4 +1,5 @@ use crate::app::{App, Mode}; +use crate::config::ConnectionType; use anyhow::Result; use crossterm::{ cursor::{Hide, Show}, @@ -7,6 +8,7 @@ use crossterm::{ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use ratatui::{Terminal, backend::CrosstermBackend}; +use std::collections::HashSet; use std::io; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -24,6 +26,8 @@ pub fn run() -> Result<()> { let mut terminal = Terminal::new(backend)?; let mut app = App::load()?; + spawn_latency_probes(&app); + loop { terminal.draw(|frame| super::draw(frame, &mut app))?; if app.session.should_quit { @@ -34,6 +38,7 @@ pub fn run() -> Result<()> { && key.kind == KeyEventKind::Press { handle_key(&mut app, key)?; + spawn_latency_probes(&app); } } @@ -83,3 +88,27 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> { Mode::Settings => SettingsView.handle_key(app, key), } } + +fn spawn_latency_probes(app: &App) { + let cache = app.session.latency.lock().unwrap(); + let existing: HashSet = cache.keys().cloned().collect(); + drop(cache); + + for (_, profile) in app.entries() { + if let ConnectionType::Ssh { host, port, .. } = &profile.kind { + let key = format!("{host}:{port}"); + if existing.contains(&key) { + continue; + } + let cache_clone = app.session.latency.clone(); + let host = host.clone(); + let port = *port; + std::thread::spawn(move || { + let status = crate::app::latency::probe(&host, port); + if let Ok(mut cache) = cache_clone.lock() { + cache.insert(key, status); + } + }); + } + } +} diff --git a/src/ui/view/home_list.rs b/src/ui/view/home_list.rs index c49c245..62bf67a 100644 --- a/src/ui/view/home_list.rs +++ b/src/ui/view/home_list.rs @@ -1,8 +1,9 @@ use crate::app::{App, Mode}; use crate::app::display_name; +use crate::app::latency::LatencyStatus; use crate::config::{ConnectionSource, ConnectionType, CredentialEntry}; use crate::ui::component::{badge_span, draw_input, panel, tag_badge}; -use crate::ui::{ACCENT, BLUE, GREEN, MUTED, PANEL_ALT, PURPLE, RED, SELECTED_BG, TEXT}; +use crate::ui::{ACCENT, BLUE, GREEN, MUTED, PANEL_ALT, PURPLE, RED, SELECTED_BG, TEXT, YELLOW}; use super::View; @@ -193,7 +194,7 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) { ], ) .header( - Row::new([" Name", "Type", "Target", "Auth"]) + Row::new([" Name", "Type", "Target", "Ping"]) .style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), ) .block(panel(title)) @@ -264,15 +265,27 @@ fn connection_row( } }; - let auth_state = profile - .auth_ref() - .and_then(|auth| app.config.credential(auth)) - .map(|cred| if cred.has_value() { "ready" } else { "empty" }) - .unwrap_or("none"); - let auth_color = match auth_state { - "ready" => GREEN, - "empty" => RED, - _ => MUTED, + let (ping_text, ping_color) = match &profile.kind { + ConnectionType::Ssh { host, port, .. } => { + let key = format!("{host}:{port}"); + let cache = app.session.latency.lock().unwrap(); + match cache.get(&key) { + Some(LatencyStatus::Reachable { ms }) => { + let text = format!("{ms} ms"); + let color = if *ms < 100 { + GREEN + } else if *ms < 300 { + YELLOW + } else { + RED + }; + (text, color) + } + Some(LatencyStatus::Unreachable) => ("timeout".to_string(), RED), + _ => ("...".to_string(), MUTED), + } + } + ConnectionType::Shell { .. } => ("-".to_string(), MUTED), }; let badge_style = if selected { @@ -287,10 +300,10 @@ fn connection_row( Span::styled(format!(" {} ", type_badge), badge_style), ])).style(row_style), Cell::from(target).style(row_style), - Cell::from(auth_state).style(if selected { - Style::default().fg(auth_color).bg(SELECTED_BG) + Cell::from(ping_text).style(if selected { + Style::default().fg(ping_color).bg(SELECTED_BG) } else { - Style::default().fg(auth_color) + Style::default().fg(ping_color) }), ]) .height(1) diff --git a/sshell.iss b/sshell.iss new file mode 100644 index 0000000..2701866 --- /dev/null +++ b/sshell.iss @@ -0,0 +1,61 @@ +#define MyAppName "sshell" +#define MyAppVersion GetEnv("SHELL_VERSION") +#ifndef MyAppVersion + #define MyAppVersion "0.1.0" +#endif +#define MyAppPublisher "rain-bus" +#define MyAppURL "https://github.com/Rain-Bus/sshell" +#define MyAppExeName "sshell.exe" + +[Setup] +AppId={{sshell-2024-1} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputBaseFilename=sshell-{#MyAppVersion}-{#MyArch}-windows-setup +Compression=lzma2 +SolidCompression=yes +WizardStyle=modern +ArchitecturesAllowed={#MyArchAllowed} +ArchitecturesInstallIn64BitMode={#MyArchInstall64} +OutputDir=. + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "addtopath"; Description: "Add sshell to PATH"; GroupDescription: "Environment:"; Flags: checked + +[Files] +Source: "sshell.exe"; DestDir: "{app}"; Flags: ignoreversion + +[Registry] +Root: HKLM; Subkey: "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"; \ + ValueType: expandsz; ValueName: "Path"; ValueData: "{olddata};{app}"; \ + Flags: preservestringtype uninsdeletevalue; \ + Tasks: addtopath + +[UninstallDelete] +Type: files; Name: "{app}\sshell.exe" + +[Code] +const + WM_SETTINGCHANGE = $001A; + +procedure CurStepChanged(CurStep: TSetupStep); +var + ResultCode: Integer; +begin + if CurStep = ssPostInstall then + begin + // Notify the system that environment variables have changed + RegWriteStringValue(HKLM, 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', + 'Path', RegQueryStringValue(HKLM, 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', 'Path')); + Exec('cmd.exe', '/C echo %Path%', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + end; +end;