feat: implement local terminal support with shell configuration and UI integration

This commit is contained in:
Andy Dixon
2026-05-21 13:13:06 +01:00
parent 3ff04feb92
commit a7cf28b404
21 changed files with 626 additions and 3 deletions
+42
View File
@@ -24,6 +24,7 @@ import (
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/exportimport"
"github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/preferences"
"github.com/andydixon/nexus/internal/secrets"
@@ -369,6 +370,24 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
return *info, nil
}
// ConnectLocal opens a local shell session using the configured (or default) shell.
func (s *Service) ConnectLocal() (models.SessionInfo, error) {
shell := ""
if s.prefs != nil {
prefs := s.prefs.Get()
shell = prefs.LocalShell
}
sessionID, err := s.sessions.ConnectLocal(shell)
if err != nil {
return models.SessionInfo{}, err
}
info := s.sessions.GetSessionInfo(sessionID)
if info == nil {
return models.SessionInfo{}, fmt.Errorf("session created but info not found")
}
return *info, nil
}
// DisconnectSession closes a session by ID.
func (s *Service) DisconnectSession(sessionID string) error {
return s.sessions.Disconnect(sessionID)
@@ -923,6 +942,29 @@ func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) {
return s.sessions.GetVNCWebSocketURL(sessionID)
}
// ---------------------------------------------------------------------------
// Local shell
// ---------------------------------------------------------------------------
// GetDefaultShell returns the system's default shell path.
func (s *Service) GetDefaultShell() string {
return localshell.DefaultShell()
}
// BrowseForShell opens a file dialog to select a shell binary.
func (s *Service) BrowseForShell() (string, error) {
if s.ctx == nil {
return "", fmt.Errorf("application context not set")
}
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
Title: "Select Shell Binary",
})
if err != nil {
return "", fmt.Errorf("file dialog failed: %w", err)
}
return path, nil
}
// ---------------------------------------------------------------------------
// Port forwarding
// ---------------------------------------------------------------------------
+190
View File
@@ -0,0 +1,190 @@
// Package localshell provides a local PTY-based terminal session.
// It spawns a shell process and bridges I/O for the frontend terminal.
package localshell
import (
"fmt"
"os"
"os/exec"
"runtime"
"sync"
"github.com/creack/pty"
)
// Client manages a local shell PTY session.
type Client struct {
mu sync.Mutex
cmd *exec.Cmd
ptmx *os.File
shell string
onData func([]byte)
onError func(error)
onClose func()
closed bool
cols uint16
rows uint16
}
// Config holds configuration for a local shell session.
type Config struct {
Shell string // Path to the shell binary (e.g. /bin/zsh)
Cols uint16
Rows uint16
OnData func([]byte)
OnError func(error)
OnClose func()
}
// DefaultShell returns the system default shell.
func DefaultShell() string {
if runtime.GOOS == "windows" {
// Prefer PowerShell if available, else cmd.exe.
if ps, err := exec.LookPath("powershell.exe"); err == nil {
return ps
}
return "cmd.exe"
}
// Unix: use SHELL env var, fallback to /bin/sh.
if sh := os.Getenv("SHELL"); sh != "" {
return sh
}
return "/bin/sh"
}
// NewClient creates a new local shell client.
func NewClient(cfg Config) *Client {
shell := cfg.Shell
if shell == "" {
shell = DefaultShell()
}
cols := cfg.Cols
if cols == 0 {
cols = 80
}
rows := cfg.Rows
if rows == 0 {
rows = 24
}
return &Client{
shell: shell,
cols: cols,
rows: rows,
onData: cfg.OnData,
onError: cfg.OnError,
onClose: cfg.OnClose,
}
}
// Start spawns the shell process and begins reading output.
func (c *Client) Start() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ptmx != nil {
return fmt.Errorf("shell already running")
}
cmd := exec.Command(c.shell)
cmd.Env = os.Environ()
// Start in the user's home directory.
if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home
}
// Set TERM for proper terminal behaviour.
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Cols: c.cols,
Rows: c.rows,
})
if err != nil {
return fmt.Errorf("failed to start shell %q: %w", c.shell, err)
}
c.cmd = cmd
c.ptmx = ptmx
// Read loop — forward PTY output to the frontend.
go c.readLoop()
// Wait for process exit.
go func() {
_ = cmd.Wait()
c.mu.Lock()
wasClosed := c.closed
c.closed = true
c.mu.Unlock()
if !wasClosed && c.onClose != nil {
c.onClose()
}
}()
return nil
}
// Write sends data to the shell's stdin (PTY).
func (c *Client) Write(data []byte) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return fmt.Errorf("shell not running")
}
_, err := ptmx.Write(data)
return err
}
// Resize updates the PTY window size.
func (c *Client) Resize(cols, rows uint16) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return nil
}
return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// Close terminates the shell process and PTY.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.closed = true
if c.ptmx != nil {
c.ptmx.Close()
c.ptmx = nil
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
}
func (c *Client) readLoop() {
buf := make([]byte, 32*1024)
for {
n, err := c.ptmx.Read(buf)
if n > 0 && c.onData != nil {
c.onData(buf[:n])
}
if err != nil {
c.mu.Lock()
wasClosed := c.closed
c.mu.Unlock()
if !wasClosed && c.onError != nil {
c.onError(err)
}
return
}
}
}
+1
View File
@@ -51,6 +51,7 @@ func DefaultAppPreferences() AppPreferences {
DefaultScrollback: DefaultScrollback,
DefaultCursorStyle: DefaultCursorStyle,
UseNativePaste: false,
LocalShell: "", // empty means use system default
}
}
+2
View File
@@ -13,6 +13,7 @@ const (
ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc"
ProtocolLocal Protocol = "local"
)
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
@@ -302,6 +303,7 @@ type AppPreferences struct {
DefaultScrollback int `json:"defaultScrollback"`
DefaultCursorStyle string `json:"defaultCursorStyle"`
UseNativePaste bool `json:"useNativePaste"`
LocalShell string `json:"localShell"` // path to shell binary for local terminal
}
// AuditEntry records an auditable action within the application.
+108
View File
@@ -18,6 +18,7 @@ import (
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/githubclient"
"github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/rawclient"
"github.com/andydixon/nexus/internal/recording"
@@ -38,6 +39,7 @@ type Session struct {
githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
localClient *localshell.Client // local shell PTY, nil for non-local sessions
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile
recorder *recording.Recorder // active recording, nil if not recording
@@ -163,6 +165,104 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
return sessionID, nil
}
// ConnectLocal creates a local shell session without a saved profile.
// The shell path is resolved from the given parameter or falls back to the system default.
func (m *Manager) ConnectLocal(shell string) (string, error) {
sessionID := uuid.New().String()
now := time.Now()
info := models.SessionInfo{
ID: sessionID,
ProfileID: "",
ProfileName: "Local Machine",
Protocol: models.ProtocolLocal,
Host: "localhost",
Port: 0,
State: models.SessionConnecting,
TabName: "Local",
ConnectedAt: now,
}
sess := &Session{
Info: info,
}
m.mu.Lock()
m.sessions[sessionID] = sess
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnecting),
})
go func() {
client := localshell.NewClient(localshell.Config{
Shell: shell,
Cols: 80,
Rows: 24,
OnData: func(data []byte) {
m.emitEvent("session:data", map[string]interface{}{
"sessionID": sessionID,
"data": base64.StdEncoding.EncodeToString(data),
})
// Write to active recording if present.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteOutput(data)
}
},
OnError: func(err error) {
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
},
OnClose: func() {
m.handleSessionClosed(sessionID)
},
})
if err := client.Start(); err != nil {
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
return
}
sess.localClient = client
m.mu.Lock()
sess.Info.State = models.SessionConnected
sess.Info.ConnectedAt = time.Now()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnected),
})
m.auditLog.LogConnect("", sessionID, "local", "localhost")
}()
return sessionID, nil
}
// Disconnect closes a specific session.
func (m *Manager) Disconnect(sessionID string) error {
m.mu.Lock()
@@ -240,6 +340,8 @@ func (m *Manager) WriteToSession(sessionID string, data []byte) error {
return sess.telnetClient.Write(data)
case sess.rawClient != nil:
return sess.rawClient.Write(data)
case sess.localClient != nil:
return sess.localClient.Write(data)
}
return fmt.Errorf("sessions: no active client for session %q", sessionID)
}
@@ -259,6 +361,8 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
return sess.sshClient.Resize(cols, rows)
case sess.telnetClient != nil:
return sess.telnetClient.Resize(cols, rows)
case sess.localClient != nil:
return sess.localClient.Resize(uint16(cols), uint16(rows))
}
// RAW sessions have no concept of terminal size.
return nil
@@ -1490,6 +1594,10 @@ func (m *Manager) closeSession(sess *Session) {
sess.vncClient.Close()
sess.vncClient = nil
}
if sess.localClient != nil {
sess.localClient.Close()
sess.localClient = nil
}
m.mu.Lock()
sess.Info.State = models.SessionDisconnected