Phase 4: SFTP file browser, SSH config import, and update checking
SFTP file browser: - Created internal/sftpclient wrapping github.com/pkg/sftp for remote file operations (list, stat, mkdir, remove, rename, download, upload, pwd) - Integrated SFTP into session manager with lazy client creation per session - Added API methods for all SFTP operations with native file dialogs - Built SFTPBrowser frontend component — right-side resizable panel with breadcrumb navigation, sorted file listing, context actions (download, rename, delete), upload, new folder, and custom modal dialogs - Toggle via Cmd+Shift+F on active SSH sessions - Auto-refits terminal when panel opens/closes/resizes - Panel cleaned up automatically when session closes OpenSSH config import: - Created internal/sshconfig parser for ~/.ssh/config Host blocks - Converts to profiles with hostname, port, user, identity file, ProxyJump (first hop), ForwardAgent, and Compression support - Skips wildcard-only hosts and Match blocks - Import button added to Settings > About tab Automatic update checking: - Created internal/updater querying GitHub Releases API - Simple semver comparison, 10-second timeout - Dev builds never trigger update prompts - Check button added to Settings > About tab
This commit is contained in:
@@ -21,7 +21,9 @@ import (
|
||||
"github.com/copilot-ssh-client/app/internal/preferences"
|
||||
"github.com/copilot-ssh-client/app/internal/secrets"
|
||||
"github.com/copilot-ssh-client/app/internal/sessions"
|
||||
"github.com/copilot-ssh-client/app/internal/sshconfig"
|
||||
"github.com/copilot-ssh-client/app/internal/storage"
|
||||
"github.com/copilot-ssh-client/app/internal/updater"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -852,6 +854,144 @@ func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SFTP file browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SFTPList lists the contents of a remote directory.
|
||||
func (s *Service) SFTPList(sessionID string, remotePath string) ([]map[string]interface{}, error) {
|
||||
entries, err := s.sessions.SFTPList(sessionID, remotePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]map[string]interface{}, len(entries))
|
||||
for i, e := range entries {
|
||||
result[i] = map[string]interface{}{
|
||||
"name": e.Name,
|
||||
"path": e.Path,
|
||||
"size": e.Size,
|
||||
"isDir": e.IsDir,
|
||||
"mode": e.Mode,
|
||||
"modifiedAt": e.ModifiedAt,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SFTPMkdir creates a remote directory (including parents).
|
||||
func (s *Service) SFTPMkdir(sessionID string, remotePath string) error {
|
||||
return s.sessions.SFTPMkdir(sessionID, remotePath)
|
||||
}
|
||||
|
||||
// SFTPRemove deletes a remote file or empty directory.
|
||||
func (s *Service) SFTPRemove(sessionID string, remotePath string) error {
|
||||
return s.sessions.SFTPRemove(sessionID, remotePath)
|
||||
}
|
||||
|
||||
// SFTPRename moves or renames a remote file/directory.
|
||||
func (s *Service) SFTPRename(sessionID string, oldPath string, newPath string) error {
|
||||
return s.sessions.SFTPRename(sessionID, oldPath, newPath)
|
||||
}
|
||||
|
||||
// SFTPDownload downloads a remote file. Opens a native save dialog for the
|
||||
// local destination.
|
||||
func (s *Service) SFTPDownload(sessionID string, remotePath string) error {
|
||||
if s.ctx == nil {
|
||||
return fmt.Errorf("application context not set")
|
||||
}
|
||||
|
||||
localPath, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
|
||||
Title: "Save File",
|
||||
DefaultFilename: filepath.Base(remotePath),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("save dialog failed: %w", err)
|
||||
}
|
||||
if localPath == "" {
|
||||
return fmt.Errorf("download cancelled")
|
||||
}
|
||||
|
||||
return s.sessions.SFTPDownload(sessionID, remotePath, localPath)
|
||||
}
|
||||
|
||||
// SFTPUpload uploads a local file to a remote directory. Opens a native file
|
||||
// dialog to select the local file.
|
||||
func (s *Service) SFTPUpload(sessionID string, remotePath string) error {
|
||||
if s.ctx == nil {
|
||||
return fmt.Errorf("application context not set")
|
||||
}
|
||||
|
||||
localPath, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
|
||||
Title: "Select File to Upload",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open dialog failed: %w", err)
|
||||
}
|
||||
if localPath == "" {
|
||||
return fmt.Errorf("upload cancelled")
|
||||
}
|
||||
|
||||
destPath := remotePath + "/" + filepath.Base(localPath)
|
||||
return s.sessions.SFTPUpload(sessionID, localPath, destPath)
|
||||
}
|
||||
|
||||
// SFTPPwd returns the current working directory on the remote host.
|
||||
func (s *Service) SFTPPwd(sessionID string) (string, error) {
|
||||
return s.sessions.SFTPPwd(sessionID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenSSH config import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ImportSSHConfig parses the given SSH config file (or the default
|
||||
// ~/.ssh/config if path is empty) and imports the Host blocks as profiles.
|
||||
func (s *Service) ImportSSHConfig(configPath string, folder string) (int, error) {
|
||||
if configPath == "" {
|
||||
configPath = sshconfig.DefaultConfigPath()
|
||||
}
|
||||
|
||||
blocks, err := sshconfig.ParseFile(configPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
profiles := sshconfig.ToProfiles(blocks, folder)
|
||||
imported := 0
|
||||
for _, p := range profiles {
|
||||
if err := s.store.CreateProfile(&p); err != nil {
|
||||
continue // skip duplicates or errors
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
s.auditLog.LogImport(imported)
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Automatic update checking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CheckForUpdates queries GitHub Releases for a newer version.
|
||||
func (s *Service) CheckForUpdates() (map[string]interface{}, error) {
|
||||
checker := updater.NewChecker(appVersion)
|
||||
info, err := checker.Check(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"available": info.Available,
|
||||
"currentVersion": info.CurrentVersion,
|
||||
"latestVersion": info.LatestVersion,
|
||||
"releaseURL": info.ReleaseURL,
|
||||
"releaseName": info.ReleaseName,
|
||||
"releaseNotes": info.ReleaseNotes,
|
||||
"publishedAt": info.PublishedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestConnection performs a basic TCP reachability check to the given host and port.
|
||||
// This verifies the network path without establishing a full protocol session.
|
||||
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/copilot-ssh-client/app/internal/rawclient"
|
||||
"github.com/copilot-ssh-client/app/internal/recording"
|
||||
"github.com/copilot-ssh-client/app/internal/secrets"
|
||||
"github.com/copilot-ssh-client/app/internal/sftpclient"
|
||||
"github.com/copilot-ssh-client/app/internal/sshclient"
|
||||
"github.com/copilot-ssh-client/app/internal/storage"
|
||||
"github.com/copilot-ssh-client/app/internal/telnetclient"
|
||||
@@ -32,6 +33,7 @@ type Session struct {
|
||||
rawClient *rawclient.Client
|
||||
githubClient *githubclient.Client
|
||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
|
||||
profile *models.Profile
|
||||
recorder *recording.Recorder // active recording, nil if not recording
|
||||
}
|
||||
@@ -826,6 +828,137 @@ func (m *Manager) DeleteRecording(id string) error {
|
||||
return m.recordStore.Delete(id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SFTP file browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// getSFTP returns or lazily creates the SFTP client for an SSH session.
|
||||
func (m *Manager) getSFTP(sessionID string) (*sftpclient.Client, error) {
|
||||
m.mu.Lock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
||||
}
|
||||
if sess.sshClient == nil {
|
||||
m.mu.Unlock()
|
||||
return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID)
|
||||
}
|
||||
|
||||
// Return existing SFTP client if already open.
|
||||
if sess.sftpClient != nil {
|
||||
c := sess.sftpClient
|
||||
m.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Create a new SFTP subsystem.
|
||||
sshConn := sess.sshClient.SSHConn()
|
||||
if sshConn == nil {
|
||||
return nil, fmt.Errorf("sessions: SSH connection is not established")
|
||||
}
|
||||
|
||||
sc, err := sftpclient.NewClient(sshConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
sess.sftpClient = sc
|
||||
m.mu.Unlock()
|
||||
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// SFTPList lists the contents of a remote directory.
|
||||
func (m *Manager) SFTPList(sessionID, remotePath string) ([]sftpclient.FileEntry, error) {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sc.List(remotePath)
|
||||
}
|
||||
|
||||
// SFTPStat returns information about a remote path.
|
||||
func (m *Manager) SFTPStat(sessionID, remotePath string) (*sftpclient.FileEntry, error) {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sc.Stat(remotePath)
|
||||
}
|
||||
|
||||
// SFTPMkdir creates a remote directory.
|
||||
func (m *Manager) SFTPMkdir(sessionID, remotePath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.MkdirAll(remotePath)
|
||||
}
|
||||
|
||||
// SFTPRemove deletes a remote file or empty directory.
|
||||
func (m *Manager) SFTPRemove(sessionID, remotePath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.Remove(remotePath)
|
||||
}
|
||||
|
||||
// SFTPRename moves or renames a remote file/directory.
|
||||
func (m *Manager) SFTPRename(sessionID, oldPath, newPath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
// SFTPDownload downloads a remote file to a local path.
|
||||
func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) {
|
||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"transferred": p.Transferred,
|
||||
"total": p.Total,
|
||||
"path": p.Path,
|
||||
"done": p.Done,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// SFTPUpload uploads a local file to a remote path.
|
||||
func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) {
|
||||
m.emitEvent("session:sftp:progress", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"transferred": p.Transferred,
|
||||
"total": p.Total,
|
||||
"path": p.Path,
|
||||
"done": p.Done,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// SFTPPwd returns the current working directory on the remote host.
|
||||
func (m *Manager) SFTPPwd(sessionID string) (string, error) {
|
||||
sc, err := m.getSFTP(sessionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sc.Pwd()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port-forwarding management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -992,6 +1125,12 @@ func (m *Manager) closeSession(sess *Session) {
|
||||
sess.recorder = nil
|
||||
}
|
||||
|
||||
// Close SFTP subsystem if open.
|
||||
if sess.sftpClient != nil {
|
||||
sess.sftpClient.Close()
|
||||
sess.sftpClient = nil
|
||||
}
|
||||
|
||||
if sess.sshClient != nil {
|
||||
sess.sshClient.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// Package sftpclient provides an SFTP file browser and transfer capability
|
||||
// over an established SSH connection. It wraps github.com/pkg/sftp to provide
|
||||
// directory listing, upload, download, rename, delete, and mkdir operations
|
||||
// that the frontend can call through the Wails API.
|
||||
//
|
||||
// The client is created per-session — each SSH session can have at most one
|
||||
// SFTP subsystem active.
|
||||
package sftpclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// FileEntry represents a single file or directory in a remote listing.
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"` // full remote path
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Mode string `json:"mode"` // Unix permission string, e.g. "-rwxr-xr-x"
|
||||
ModifiedAt int64 `json:"modifiedAt"` // Unix epoch milliseconds
|
||||
Owner string `json:"owner"` // best-effort UID
|
||||
}
|
||||
|
||||
// TransferProgress reports the state of a file transfer.
|
||||
type TransferProgress struct {
|
||||
Transferred int64 `json:"transferred"` // bytes transferred so far
|
||||
Total int64 `json:"total"` // total bytes (0 if unknown)
|
||||
Path string `json:"path"` // file being transferred
|
||||
Done bool `json:"done"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Client wraps an SFTP session.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
sftp *sftp.Client
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewClient creates an SFTP subsystem over an existing SSH connection.
|
||||
func NewClient(sshConn *ssh.Client) (*Client, error) {
|
||||
sc, err := sftp.NewClient(sshConn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sftpclient: failed to start SFTP subsystem: %w", err)
|
||||
}
|
||||
return &Client{sftp: sc}, nil
|
||||
}
|
||||
|
||||
// Close shuts down the SFTP subsystem.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
return nil
|
||||
}
|
||||
c.closed = true
|
||||
if c.sftp != nil {
|
||||
return c.sftp.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the contents of a remote directory.
|
||||
func (c *Client) List(remotePath string) ([]FileEntry, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed || c.sftp == nil {
|
||||
return nil, fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
|
||||
entries, err := c.sftp.ReadDir(remotePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sftpclient: failed to list %s: %w", remotePath, err)
|
||||
}
|
||||
|
||||
result := make([]FileEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
fe := FileEntry{
|
||||
Name: e.Name(),
|
||||
Path: path.Join(remotePath, e.Name()),
|
||||
Size: e.Size(),
|
||||
IsDir: e.IsDir(),
|
||||
Mode: e.Mode().String(),
|
||||
ModifiedAt: e.ModTime().UnixMilli(),
|
||||
}
|
||||
result = append(result, fe)
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically.
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].IsDir != result[j].IsDir {
|
||||
return result[i].IsDir
|
||||
}
|
||||
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Stat returns information about a single remote path.
|
||||
func (c *Client) Stat(remotePath string) (*FileEntry, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed || c.sftp == nil {
|
||||
return nil, fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
|
||||
info, err := c.sftp.Stat(remotePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sftpclient: stat failed for %s: %w", remotePath, err)
|
||||
}
|
||||
|
||||
return &FileEntry{
|
||||
Name: info.Name(),
|
||||
Path: remotePath,
|
||||
Size: info.Size(),
|
||||
IsDir: info.IsDir(),
|
||||
Mode: info.Mode().String(),
|
||||
ModifiedAt: info.ModTime().UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Mkdir creates a remote directory.
|
||||
func (c *Client) Mkdir(remotePath string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed || c.sftp == nil {
|
||||
return fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
|
||||
return c.sftp.Mkdir(remotePath)
|
||||
}
|
||||
|
||||
// MkdirAll creates a remote directory and all parents.
|
||||
func (c *Client) MkdirAll(remotePath string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed || c.sftp == nil {
|
||||
return fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
|
||||
return c.sftp.MkdirAll(remotePath)
|
||||
}
|
||||
|
||||
// Remove deletes a remote file or empty directory.
|
||||
func (c *Client) Remove(remotePath string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed || c.sftp == nil {
|
||||
return fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
|
||||
return c.sftp.Remove(remotePath)
|
||||
}
|
||||
|
||||
// Rename moves or renames a remote file/directory.
|
||||
func (c *Client) Rename(oldPath, newPath string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed || c.sftp == nil {
|
||||
return fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
|
||||
return c.sftp.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
// Download copies a remote file to a local path. The onProgress callback
|
||||
// is invoked periodically with transfer progress (may be nil).
|
||||
func (c *Client) Download(remotePath, localPath string, onProgress func(TransferProgress)) error {
|
||||
c.mu.Lock()
|
||||
if c.closed || c.sftp == nil {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
|
||||
// Get file size for progress reporting.
|
||||
info, err := c.sftp.Stat(remotePath)
|
||||
if err != nil {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("sftpclient: stat failed: %w", err)
|
||||
}
|
||||
totalSize := info.Size()
|
||||
|
||||
remote, err := c.sftp.Open(remotePath)
|
||||
if err != nil {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("sftpclient: failed to open remote file: %w", err)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
defer remote.Close()
|
||||
|
||||
local, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftpclient: failed to create local file: %w", err)
|
||||
}
|
||||
defer local.Close()
|
||||
|
||||
return c.copyWithProgress(remote, local, totalSize, remotePath, onProgress)
|
||||
}
|
||||
|
||||
// Upload copies a local file to a remote path. The onProgress callback
|
||||
// is invoked periodically with transfer progress (may be nil).
|
||||
func (c *Client) Upload(localPath, remotePath string, onProgress func(TransferProgress)) error {
|
||||
local, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftpclient: failed to open local file: %w", err)
|
||||
}
|
||||
defer local.Close()
|
||||
|
||||
info, err := local.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftpclient: failed to stat local file: %w", err)
|
||||
}
|
||||
totalSize := info.Size()
|
||||
|
||||
c.mu.Lock()
|
||||
if c.closed || c.sftp == nil {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
remote, err := c.sftp.Create(remotePath)
|
||||
if err != nil {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("sftpclient: failed to create remote file: %w", err)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
defer remote.Close()
|
||||
|
||||
return c.copyWithProgress(local, remote, totalSize, remotePath, onProgress)
|
||||
}
|
||||
|
||||
// Pwd returns the current working directory on the remote host.
|
||||
func (c *Client) Pwd() (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed || c.sftp == nil {
|
||||
return "", fmt.Errorf("sftpclient: client is closed")
|
||||
}
|
||||
|
||||
return c.sftp.Getwd()
|
||||
}
|
||||
|
||||
// copyWithProgress copies from src to dst, reporting progress periodically.
|
||||
func (c *Client) copyWithProgress(src io.Reader, dst io.Writer, totalSize int64, filePath string, onProgress func(TransferProgress)) error {
|
||||
buf := make([]byte, 32*1024) // 32 KB buffer
|
||||
var transferred int64
|
||||
lastReport := time.Now()
|
||||
|
||||
for {
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
nw, wErr := dst.Write(buf[:n])
|
||||
if wErr != nil {
|
||||
return fmt.Errorf("sftpclient: write failed: %w", wErr)
|
||||
}
|
||||
transferred += int64(nw)
|
||||
|
||||
// Report progress at most every 100ms.
|
||||
if onProgress != nil && time.Since(lastReport) > 100*time.Millisecond {
|
||||
onProgress(TransferProgress{
|
||||
Transferred: transferred,
|
||||
Total: totalSize,
|
||||
Path: filePath,
|
||||
})
|
||||
lastReport = time.Now()
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("sftpclient: read failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress report.
|
||||
if onProgress != nil {
|
||||
onProgress(TransferProgress{
|
||||
Transferred: transferred,
|
||||
Total: totalSize,
|
||||
Path: filePath,
|
||||
Done: true,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -304,6 +304,14 @@ func (c *Client) Forwards() *ForwardManager {
|
||||
return c.forwards
|
||||
}
|
||||
|
||||
// SSHConn returns the underlying ssh.Client for use by subsystems such as
|
||||
// SFTP. Returns nil if not connected.
|
||||
func (c *Client) SSHConn() *ssh.Client {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.sshClient
|
||||
}
|
||||
|
||||
// buildAuthMethods constructs the SSH authentication method list from the
|
||||
// profile's SSHConfig and the provided credential callbacks.
|
||||
func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Package sshconfig parses OpenSSH ~/.ssh/config files and converts
|
||||
// Host blocks into Nexus Terminal connection profiles. Supports a useful
|
||||
// subset of SSH config directives: HostName, Port, User, IdentityFile,
|
||||
// ProxyJump, ForwardAgent, and Compression.
|
||||
//
|
||||
// Limitations (future work):
|
||||
// - Match blocks are ignored
|
||||
// - Wildcards in Host patterns are skipped
|
||||
// - Include directives are not followed
|
||||
// - Token expansion (%h, %p, etc.) is not performed
|
||||
package sshconfig
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HostBlock represents a parsed SSH config Host section.
|
||||
type HostBlock struct {
|
||||
Aliases []string
|
||||
HostName string
|
||||
Port int
|
||||
User string
|
||||
IdentityFile string
|
||||
ProxyJump string
|
||||
ForwardAgent bool
|
||||
Compression bool
|
||||
}
|
||||
|
||||
// ParseFile reads and parses an OpenSSH config file.
|
||||
func ParseFile(path string) ([]HostBlock, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sshconfig: failed to open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var blocks []HostBlock
|
||||
var current *HostBlock
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip empty lines and comments.
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split into directive and value.
|
||||
key, value := splitDirective(line)
|
||||
key = strings.ToLower(key)
|
||||
|
||||
switch key {
|
||||
case "host":
|
||||
// Commit previous block.
|
||||
if current != nil {
|
||||
blocks = append(blocks, *current)
|
||||
}
|
||||
aliases := strings.Fields(value)
|
||||
// Skip wildcard-only hosts (e.g. "Host *").
|
||||
hasWildcard := false
|
||||
for _, a := range aliases {
|
||||
if strings.Contains(a, "*") || strings.Contains(a, "?") {
|
||||
hasWildcard = true
|
||||
}
|
||||
}
|
||||
if hasWildcard && len(aliases) == 1 {
|
||||
current = nil
|
||||
continue
|
||||
}
|
||||
current = &HostBlock{
|
||||
Aliases: aliases,
|
||||
Port: 22,
|
||||
}
|
||||
|
||||
case "match":
|
||||
// Commit previous block and skip Match blocks.
|
||||
if current != nil {
|
||||
blocks = append(blocks, *current)
|
||||
}
|
||||
current = nil
|
||||
|
||||
case "hostname":
|
||||
if current != nil {
|
||||
current.HostName = value
|
||||
}
|
||||
case "port":
|
||||
if current != nil {
|
||||
if p, err := strconv.Atoi(value); err == nil {
|
||||
current.Port = p
|
||||
}
|
||||
}
|
||||
case "user":
|
||||
if current != nil {
|
||||
current.User = value
|
||||
}
|
||||
case "identityfile":
|
||||
if current != nil {
|
||||
current.IdentityFile = expandTilde(value)
|
||||
}
|
||||
case "proxyjump":
|
||||
if current != nil {
|
||||
current.ProxyJump = value
|
||||
}
|
||||
case "forwardagent":
|
||||
if current != nil {
|
||||
current.ForwardAgent = isTrueish(value)
|
||||
}
|
||||
case "compression":
|
||||
if current != nil {
|
||||
current.Compression = isTrueish(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit final block.
|
||||
if current != nil {
|
||||
blocks = append(blocks, *current)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("sshconfig: read error: %w", err)
|
||||
}
|
||||
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// DefaultConfigPath returns the standard OpenSSH config file path.
|
||||
func DefaultConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".ssh", "config")
|
||||
}
|
||||
|
||||
// ToProfiles converts parsed Host blocks into Nexus Terminal profiles.
|
||||
// Each block produces one profile; the first alias becomes the display name.
|
||||
func ToProfiles(blocks []HostBlock, folder string) []models.Profile {
|
||||
profiles := make([]models.Profile, 0, len(blocks))
|
||||
|
||||
for _, b := range blocks {
|
||||
if len(b.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
host := b.HostName
|
||||
if host == "" {
|
||||
host = b.Aliases[0]
|
||||
}
|
||||
|
||||
displayName := b.Aliases[0]
|
||||
username := b.User
|
||||
|
||||
sshConf := &models.SSHConfig{
|
||||
AuthMethods: []string{"publickey", "password"},
|
||||
SecretMode: models.SecretModeAskEveryTime,
|
||||
HostKeyPolicy: models.HostKeyAsk,
|
||||
TerminalType: "xterm-256color",
|
||||
AgentAuth: b.ForwardAgent,
|
||||
AgentForwarding: b.ForwardAgent,
|
||||
Compression: b.Compression,
|
||||
}
|
||||
|
||||
if b.IdentityFile != "" {
|
||||
sshConf.PrivateKeyPath = b.IdentityFile
|
||||
}
|
||||
|
||||
// Parse ProxyJump into a jump host.
|
||||
if b.ProxyJump != "" && b.ProxyJump != "none" {
|
||||
jh := parseProxyJump(b.ProxyJump)
|
||||
if jh != nil {
|
||||
sshConf.JumpHosts = []models.JumpHost{*jh}
|
||||
}
|
||||
}
|
||||
|
||||
profile := models.NewProfile(models.ProtocolSSH)
|
||||
profile.ID = uuid.New().String()
|
||||
profile.DisplayName = displayName
|
||||
profile.Host = host
|
||||
profile.Port = b.Port
|
||||
profile.Username = username
|
||||
profile.Folder = folder
|
||||
profile.SSH = sshConf
|
||||
profile.Tags = []string{"ssh-config-import"}
|
||||
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
|
||||
// parseProxyJump parses a simple ProxyJump value (user@host:port format).
|
||||
// Only the first hop is parsed; chained jumps are future work.
|
||||
func parseProxyJump(value string) *models.JumpHost {
|
||||
// Take only the first hop if comma-separated.
|
||||
if idx := strings.Index(value, ","); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "none" {
|
||||
return nil
|
||||
}
|
||||
|
||||
jh := &models.JumpHost{
|
||||
Port: 22,
|
||||
AuthMethod: "publickey",
|
||||
SecretMode: models.SecretModeAskEveryTime,
|
||||
}
|
||||
|
||||
// Extract user@ prefix.
|
||||
if at := strings.Index(value, "@"); at >= 0 {
|
||||
jh.Username = value[:at]
|
||||
value = value[at+1:]
|
||||
}
|
||||
|
||||
// Extract :port suffix.
|
||||
if colon := strings.LastIndex(value, ":"); colon >= 0 {
|
||||
if p, err := strconv.Atoi(value[colon+1:]); err == nil {
|
||||
jh.Port = p
|
||||
value = value[:colon]
|
||||
}
|
||||
}
|
||||
|
||||
jh.Host = value
|
||||
return jh
|
||||
}
|
||||
|
||||
// splitDirective splits a line into key and value, handling both
|
||||
// "Key Value" and "Key=Value" formats.
|
||||
func splitDirective(line string) (string, string) {
|
||||
// Try = separator first.
|
||||
if idx := strings.Index(line, "="); idx >= 0 {
|
||||
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
// Fall back to whitespace separator.
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) == 1 {
|
||||
parts = strings.SplitN(line, "\t", 2)
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return parts[0], ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
// expandTilde replaces a leading ~ with the user's home directory.
|
||||
func expandTilde(path string) string {
|
||||
if !strings.HasPrefix(path, "~/") {
|
||||
return path
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
|
||||
// isTrueish returns true for "yes", "true", "on", and "1".
|
||||
func isTrueish(val string) bool {
|
||||
switch strings.ToLower(val) {
|
||||
case "yes", "true", "on", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package sshconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testConfig = `# Test SSH config
|
||||
|
||||
Host *
|
||||
ServerAliveInterval 60
|
||||
|
||||
Host webserver
|
||||
HostName web.example.com
|
||||
Port 2222
|
||||
User deploy
|
||||
IdentityFile ~/.ssh/deploy_key
|
||||
ForwardAgent yes
|
||||
Compression yes
|
||||
|
||||
Host dbserver
|
||||
HostName db.internal.example.com
|
||||
User postgres
|
||||
ProxyJump bastion@jump.example.com:2200
|
||||
|
||||
Host simple
|
||||
HostName 10.0.0.5
|
||||
|
||||
Host wildcard-*
|
||||
User admin
|
||||
`
|
||||
|
||||
func TestParseFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config")
|
||||
if err := os.WriteFile(path, []byte(testConfig), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
blocks, err := ParseFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 3 blocks: webserver, dbserver, simple.
|
||||
// "Host *" and "Host wildcard-*" should be skipped.
|
||||
if len(blocks) != 3 {
|
||||
t.Fatalf("expected 3 blocks, got %d", len(blocks))
|
||||
}
|
||||
|
||||
// Verify webserver block.
|
||||
wb := blocks[0]
|
||||
if wb.Aliases[0] != "webserver" {
|
||||
t.Errorf("expected alias 'webserver', got %q", wb.Aliases[0])
|
||||
}
|
||||
if wb.HostName != "web.example.com" {
|
||||
t.Errorf("expected HostName 'web.example.com', got %q", wb.HostName)
|
||||
}
|
||||
if wb.Port != 2222 {
|
||||
t.Errorf("expected port 2222, got %d", wb.Port)
|
||||
}
|
||||
if wb.User != "deploy" {
|
||||
t.Errorf("expected user 'deploy', got %q", wb.User)
|
||||
}
|
||||
if !wb.ForwardAgent {
|
||||
t.Error("expected ForwardAgent to be true")
|
||||
}
|
||||
if !wb.Compression {
|
||||
t.Error("expected Compression to be true")
|
||||
}
|
||||
|
||||
// Verify dbserver block with ProxyJump.
|
||||
db := blocks[1]
|
||||
if db.Aliases[0] != "dbserver" {
|
||||
t.Errorf("expected alias 'dbserver', got %q", db.Aliases[0])
|
||||
}
|
||||
if db.ProxyJump != "bastion@jump.example.com:2200" {
|
||||
t.Errorf("unexpected ProxyJump: %q", db.ProxyJump)
|
||||
}
|
||||
|
||||
// Verify simple block.
|
||||
sb := blocks[2]
|
||||
if sb.HostName != "10.0.0.5" {
|
||||
t.Errorf("expected HostName '10.0.0.5', got %q", sb.HostName)
|
||||
}
|
||||
if sb.Port != 22 {
|
||||
t.Errorf("expected default port 22, got %d", sb.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToProfiles(t *testing.T) {
|
||||
blocks := []HostBlock{
|
||||
{
|
||||
Aliases: []string{"myserver"},
|
||||
HostName: "srv.example.com",
|
||||
Port: 22,
|
||||
User: "admin",
|
||||
ForwardAgent: true,
|
||||
},
|
||||
{
|
||||
Aliases: []string{"jumpbox"},
|
||||
HostName: "jump.example.com",
|
||||
Port: 2200,
|
||||
User: "jumper",
|
||||
ProxyJump: "gateway@gw.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
profiles := ToProfiles(blocks, "Imported")
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("expected 2 profiles, got %d", len(profiles))
|
||||
}
|
||||
|
||||
p := profiles[0]
|
||||
if p.DisplayName != "myserver" {
|
||||
t.Errorf("expected DisplayName 'myserver', got %q", p.DisplayName)
|
||||
}
|
||||
if p.Host != "srv.example.com" {
|
||||
t.Errorf("expected host 'srv.example.com', got %q", p.Host)
|
||||
}
|
||||
if p.SSH == nil {
|
||||
t.Fatal("expected SSH config to be set")
|
||||
}
|
||||
if !p.SSH.AgentAuth {
|
||||
t.Error("expected AgentAuth to be true for ForwardAgent host")
|
||||
}
|
||||
|
||||
jp := profiles[1]
|
||||
if jp.SSH == nil || len(jp.SSH.JumpHosts) == 0 {
|
||||
t.Fatal("expected jump host from ProxyJump")
|
||||
}
|
||||
jh := jp.SSH.JumpHosts[0]
|
||||
if jh.Host != "gw.example.com" {
|
||||
t.Errorf("expected jump host 'gw.example.com', got %q", jh.Host)
|
||||
}
|
||||
if jh.Username != "gateway" {
|
||||
t.Errorf("expected jump user 'gateway', got %q", jh.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProxyJump(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantHost string
|
||||
wantUser string
|
||||
wantPort int
|
||||
}{
|
||||
{"bastion.example.com", "bastion.example.com", "", 22},
|
||||
{"user@bastion.example.com", "bastion.example.com", "user", 22},
|
||||
{"user@bastion.example.com:2200", "bastion.example.com", "user", 2200},
|
||||
{"host:443", "host", "", 443},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
jh := parseProxyJump(tt.input)
|
||||
if jh == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if jh.Host != tt.wantHost {
|
||||
t.Errorf("host: got %q, want %q", jh.Host, tt.wantHost)
|
||||
}
|
||||
if jh.Username != tt.wantUser {
|
||||
t.Errorf("user: got %q, want %q", jh.Username, tt.wantUser)
|
||||
}
|
||||
if jh.Port != tt.wantPort {
|
||||
t.Errorf("port: got %d, want %d", jh.Port, tt.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package updater checks for new application releases by querying the
|
||||
// GitHub Releases API. It compares the latest published tag against the
|
||||
// current build version and reports whether an update is available.
|
||||
//
|
||||
// The check is non-blocking and opt-in — it runs in the background and
|
||||
// does not download or install anything automatically.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultOwner and defaultRepo identify the GitHub repository to check.
|
||||
// These should be overridden via SetRepository if the project is hosted
|
||||
// elsewhere.
|
||||
defaultOwner = "nexus-terminal"
|
||||
defaultRepo = "nexus-terminal"
|
||||
|
||||
// requestTimeout limits the HTTP request duration.
|
||||
requestTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Release describes a GitHub release with the fields we care about.
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Body string `json:"body"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
}
|
||||
|
||||
// UpdateInfo carries the result of an update check.
|
||||
type UpdateInfo struct {
|
||||
Available bool `json:"available"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
ReleaseURL string `json:"releaseURL"`
|
||||
ReleaseName string `json:"releaseName"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
}
|
||||
|
||||
// Checker performs version checks against GitHub Releases.
|
||||
type Checker struct {
|
||||
owner string
|
||||
repo string
|
||||
current string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewChecker creates an update checker for the given current version.
|
||||
// The version string should be a semver tag (e.g. "v1.2.3" or "1.2.3").
|
||||
func NewChecker(currentVersion string) *Checker {
|
||||
return &Checker{
|
||||
owner: defaultOwner,
|
||||
repo: defaultRepo,
|
||||
current: normaliseVersion(currentVersion),
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetRepository overrides the GitHub repository to check.
|
||||
func (c *Checker) SetRepository(owner, repo string) {
|
||||
c.owner = owner
|
||||
c.repo = repo
|
||||
}
|
||||
|
||||
// Check queries the GitHub API for the latest release and compares it to
|
||||
// the current version. Returns UpdateInfo describing the result.
|
||||
func (c *Checker) Check(ctx context.Context) (*UpdateInfo, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", c.owner, c.repo)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updater: failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "NexusTerminal-UpdateChecker")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("updater: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// No releases exist yet — not an error.
|
||||
return &UpdateInfo{
|
||||
Available: false,
|
||||
CurrentVersion: c.current,
|
||||
LatestVersion: c.current,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("updater: GitHub API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, fmt.Errorf("updater: failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// Skip drafts and pre-releases.
|
||||
if release.Draft || release.Prerelease {
|
||||
return &UpdateInfo{
|
||||
Available: false,
|
||||
CurrentVersion: c.current,
|
||||
LatestVersion: normaliseVersion(release.TagName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
latest := normaliseVersion(release.TagName)
|
||||
available := isNewer(latest, c.current)
|
||||
|
||||
return &UpdateInfo{
|
||||
Available: available,
|
||||
CurrentVersion: c.current,
|
||||
LatestVersion: latest,
|
||||
ReleaseURL: release.HTMLURL,
|
||||
ReleaseName: release.Name,
|
||||
ReleaseNotes: truncateNotes(release.Body, 500),
|
||||
PublishedAt: release.PublishedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normaliseVersion strips a leading "v" and trims whitespace.
|
||||
func normaliseVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
return v
|
||||
}
|
||||
|
||||
// isNewer performs a simple string comparison of dot-separated version parts.
|
||||
// Returns true if latest > current (basic semver, no pre-release handling).
|
||||
func isNewer(latest, current string) bool {
|
||||
if current == "" || current == "dev" {
|
||||
return false // dev builds never trigger update prompts
|
||||
}
|
||||
|
||||
lParts := strings.Split(latest, ".")
|
||||
cParts := strings.Split(current, ".")
|
||||
|
||||
maxLen := len(lParts)
|
||||
if len(cParts) > maxLen {
|
||||
maxLen = len(cParts)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var lv, cv int
|
||||
if i < len(lParts) {
|
||||
fmt.Sscanf(lParts[i], "%d", &lv)
|
||||
}
|
||||
if i < len(cParts) {
|
||||
fmt.Sscanf(cParts[i], "%d", &cv)
|
||||
}
|
||||
if lv > cv {
|
||||
return true
|
||||
}
|
||||
if lv < cv {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncateNotes trims release notes to a maximum length.
|
||||
func truncateNotes(body string, maxLen int) string {
|
||||
if len(body) <= maxLen {
|
||||
return body
|
||||
}
|
||||
return body[:maxLen] + "…"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormaliseVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"v1.2.3", "1.2.3"},
|
||||
{"1.2.3", "1.2.3"},
|
||||
{" v0.5.0 ", "0.5.0"},
|
||||
{"dev", "dev"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normaliseVersion(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normaliseVersion(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNewer(t *testing.T) {
|
||||
tests := []struct {
|
||||
latest string
|
||||
current string
|
||||
want bool
|
||||
}{
|
||||
{"1.1.0", "1.0.0", true},
|
||||
{"2.0.0", "1.9.9", true},
|
||||
{"1.0.1", "1.0.0", true},
|
||||
{"1.0.0", "1.0.0", false},
|
||||
{"0.9.0", "1.0.0", false},
|
||||
{"1.0.0", "dev", false}, // dev builds ignored
|
||||
{"1.0.0", "", false}, // empty current ignored
|
||||
{"1.2", "1.1.0", true}, // fewer parts
|
||||
{"1.0.0", "1.0.0.1", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.latest+" vs "+tt.current, func(t *testing.T) {
|
||||
got := isNewer(tt.latest, tt.current)
|
||||
if got != tt.want {
|
||||
t.Errorf("isNewer(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateNotes(t *testing.T) {
|
||||
short := "Short note"
|
||||
if got := truncateNotes(short, 100); got != short {
|
||||
t.Errorf("expected %q, got %q", short, got)
|
||||
}
|
||||
|
||||
long := strings.Repeat("x", 600)
|
||||
got := truncateNotes(long, 500)
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Error("expected truncated notes to end with ellipsis")
|
||||
}
|
||||
if len([]rune(got)) != 501 { // 500 runes + "…"
|
||||
t.Errorf("expected 501 runes, got %d", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user