feat: add latency probing, platform installers (deb/rpm/pkg/dmg/exe)
Release / Build aarch64-unknown-linux-gnu (push) Has been cancelled
Release / Build aarch64-apple-darwin (push) Has been cancelled
Release / Build aarch64-pc-windows-msvc (push) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Release / Build x86_64-apple-darwin (push) Has been cancelled
Release / Build x86_64-pc-windows-msvc (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

This commit is contained in:
2026-06-03 21:06:13 +08:00
parent 550ba9a90c
commit 285a2049cc
8 changed files with 268 additions and 18 deletions
+74 -4
View File
@@ -22,9 +22,11 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
artifact: sshell-x86_64-linux artifact: sshell-x86_64-linux
nfpm_arch: amd64
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
artifact: sshell-aarch64-linux artifact: sshell-aarch64-linux
nfpm_arch: arm64
# macOS # macOS
- os: macos-latest - os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
@@ -35,10 +37,10 @@ jobs:
# Windows # Windows
- os: windows-latest - os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
artifact: sshell-x86_64-windows.exe artifact: sshell-x86_64-windows
- os: windows-latest - os: windows-latest
target: aarch64-pc-windows-msvc target: aarch64-pc-windows-msvc
artifact: sshell-aarch64-windows.exe artifact: sshell-aarch64-windows
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -60,19 +62,77 @@ jobs:
- name: Build - name: Build
run: cargo build --release --target ${{ matrix.target }} 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' if: runner.os != 'Windows'
run: | run: |
cp target/${{ matrix.target }}/release/sshell sshell cp target/${{ matrix.target }}/release/sshell sshell
chmod +x sshell chmod +x sshell
tar czf ${{ matrix.artifact }}.tar.gz sshell tar czf ${{ matrix.artifact }}.tar.gz sshell
- name: Package (Windows) - name: Package binary archive (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
run: | run: |
copy target\${{ matrix.target }}\release\sshell.exe sshell.exe copy target\${{ matrix.target }}\release\sshell.exe sshell.exe
Compress-Archive -Path sshell.exe -DestinationPath ${{ matrix.artifact }}.zip 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 - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@@ -80,6 +140,11 @@ jobs:
path: | path: |
${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }}.tar.gz
${{ matrix.artifact }}.zip ${{ matrix.artifact }}.zip
${{ matrix.artifact }}.deb
${{ matrix.artifact }}.rpm
${{ matrix.artifact }}.pkg.tar.zst
${{ matrix.artifact }}.dmg
sshell-*-windows-setup.exe
release: release:
name: Create GitHub Release name: Create GitHub Release
@@ -100,3 +165,8 @@ jobs:
files: | files: |
artifacts/*.tar.gz artifacts/*.tar.gz
artifacts/*.zip artifacts/*.zip
artifacts/*.deb
artifacts/*.rpm
artifacts/*.pkg.tar.zst
artifacts/*.dmg
artifacts/*-windows-setup.exe
+29
View File
@@ -0,0 +1,29 @@
name: sshell
arch: ${NFPM_ARCH}
platform: linux
version: ${NFPM_VERSION}
section: utils
priority: optional
maintainer: rain-bus <rainandbus@gmail.com>
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 <rainandbus@gmail.com>
+1
View File
@@ -1,4 +1,5 @@
mod home_ops; mod home_ops;
pub mod latency;
mod profile_ext; mod profile_ext;
mod types; mod types;
+44
View File
@@ -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<Mutex<HashMap<String, LatencyStatus>>>;
/// 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,
}
}
+3
View File
@@ -2,6 +2,7 @@ use crate::import::ImportCandidate;
use super::cred::CredFormState; use super::cred::CredFormState;
use super::form::FormState; use super::form::FormState;
use super::latency::LatencyCache;
use super::settings::SettingsState; use super::settings::SettingsState;
// ── Text editing trait ────────────────────────────────────── // ── Text editing trait ──────────────────────────────────────
@@ -183,6 +184,7 @@ pub struct Session {
pub toast: Option<Toast>, pub toast: Option<Toast>,
pub should_quit: bool, pub should_quit: bool,
pub settings: SettingsState, pub settings: SettingsState,
pub latency: LatencyCache,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -219,6 +221,7 @@ impl Session {
toast: None, toast: None,
should_quit: false, should_quit: false,
settings: SettingsState::default(), settings: SettingsState::default(),
latency: LatencyCache::default(),
} }
} }
} }
+29
View File
@@ -1,4 +1,5 @@
use crate::app::{App, Mode}; use crate::app::{App, Mode};
use crate::config::ConnectionType;
use anyhow::Result; use anyhow::Result;
use crossterm::{ use crossterm::{
cursor::{Hide, Show}, cursor::{Hide, Show},
@@ -7,6 +8,7 @@ use crossterm::{
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
}; };
use ratatui::{Terminal, backend::CrosstermBackend}; use ratatui::{Terminal, backend::CrosstermBackend};
use std::collections::HashSet;
use std::io; use std::io;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
@@ -24,6 +26,8 @@ pub fn run() -> Result<()> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
let mut app = App::load()?; let mut app = App::load()?;
spawn_latency_probes(&app);
loop { loop {
terminal.draw(|frame| super::draw(frame, &mut app))?; terminal.draw(|frame| super::draw(frame, &mut app))?;
if app.session.should_quit { if app.session.should_quit {
@@ -34,6 +38,7 @@ pub fn run() -> Result<()> {
&& key.kind == KeyEventKind::Press && key.kind == KeyEventKind::Press
{ {
handle_key(&mut app, key)?; 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), Mode::Settings => SettingsView.handle_key(app, key),
} }
} }
fn spawn_latency_probes(app: &App) {
let cache = app.session.latency.lock().unwrap();
let existing: HashSet<String> = 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);
}
});
}
}
}
+27 -14
View File
@@ -1,8 +1,9 @@
use crate::app::{App, Mode}; use crate::app::{App, Mode};
use crate::app::display_name; use crate::app::display_name;
use crate::app::latency::LatencyStatus;
use crate::config::{ConnectionSource, ConnectionType, CredentialEntry}; use crate::config::{ConnectionSource, ConnectionType, CredentialEntry};
use crate::ui::component::{badge_span, draw_input, panel, tag_badge}; 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; use super::View;
@@ -193,7 +194,7 @@ pub fn draw_connection_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
], ],
) )
.header( .header(
Row::new([" Name", "Type", "Target", "Auth"]) Row::new([" Name", "Type", "Target", "Ping"])
.style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)), .style(Style::default().fg(BLUE).add_modifier(Modifier::BOLD)),
) )
.block(panel(title)) .block(panel(title))
@@ -264,15 +265,27 @@ fn connection_row(
} }
}; };
let auth_state = profile let (ping_text, ping_color) = match &profile.kind {
.auth_ref() ConnectionType::Ssh { host, port, .. } => {
.and_then(|auth| app.config.credential(auth)) let key = format!("{host}:{port}");
.map(|cred| if cred.has_value() { "ready" } else { "empty" }) let cache = app.session.latency.lock().unwrap();
.unwrap_or("none"); match cache.get(&key) {
let auth_color = match auth_state { Some(LatencyStatus::Reachable { ms }) => {
"ready" => GREEN, let text = format!("{ms} ms");
"empty" => RED, let color = if *ms < 100 {
_ => MUTED, 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 { let badge_style = if selected {
@@ -287,10 +300,10 @@ fn connection_row(
Span::styled(format!(" {} ", type_badge), badge_style), Span::styled(format!(" {} ", type_badge), badge_style),
])).style(row_style), ])).style(row_style),
Cell::from(target).style(row_style), Cell::from(target).style(row_style),
Cell::from(auth_state).style(if selected { Cell::from(ping_text).style(if selected {
Style::default().fg(auth_color).bg(SELECTED_BG) Style::default().fg(ping_color).bg(SELECTED_BG)
} else { } else {
Style::default().fg(auth_color) Style::default().fg(ping_color)
}), }),
]) ])
.height(1) .height(1)
+61
View File
@@ -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;