feat: complete Nexus Terminal — premium SSH/Telnet/RAW TCP desktop client
Production-minded Wails v2 desktop application with: - Full SSH client (password, key, keyboard-interactive, jump hosts, keepalive) - Telnet client (RFC 854/855 negotiation, NAWS, TTYPE, encoding) - RAW TCP client (binary-safe, text/hex/split views, payload presets) - SQLite storage with migrations and repository abstraction - Encrypted secret storage (AES-256-GCM + Argon2id) with keychain fallback - Known hosts verification (strict/ask/accept-new/insecure) - Session manager with multi-session lifecycle and Wails events - Dark charcoal Material-inspired design system (CSS custom properties) - Tabbed session management with rename, reorder, pin, context menu - Profile editor with protocol-aware form sections - Command palette, notifications, settings modal - JSON import/export, audit logging, preferences - 50+ tests across validation, secrets, storage, encoding, sessions - Makefile, build.sh, .gitignore, comprehensive README
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
package rawclient
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
)
|
||||
|
||||
// ParseHexString converts a hex string (with optional spaces and 0x prefixes)
|
||||
// to raw bytes.
|
||||
//
|
||||
// Accepted formats:
|
||||
//
|
||||
// "48 65 6c 6c 6f"
|
||||
// "48656c6c6f"
|
||||
// "0x48 0x65 0x6c 0x6c 0x6f"
|
||||
func ParseHexString(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("empty hex string")
|
||||
}
|
||||
|
||||
// Strip 0x/0X prefixes.
|
||||
s = strings.ReplaceAll(s, "0x", "")
|
||||
s = strings.ReplaceAll(s, "0X", "")
|
||||
|
||||
// Collect only hex digit characters.
|
||||
var cleaned strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
if !isHexDigit(r) {
|
||||
return nil, fmt.Errorf("invalid character in hex string: %q", r)
|
||||
}
|
||||
cleaned.WriteRune(r)
|
||||
}
|
||||
|
||||
if cleaned.Len()%2 != 0 {
|
||||
return nil, fmt.Errorf("hex string has odd number of digits")
|
||||
}
|
||||
|
||||
return hex.DecodeString(cleaned.String())
|
||||
}
|
||||
|
||||
// FormatHex formats bytes as a space-separated hex string.
|
||||
// Example output: "48 65 6c 6c 6f"
|
||||
func FormatHex(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(data))
|
||||
for i, b := range data {
|
||||
parts[i] = fmt.Sprintf("%02x", b)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// FormatHexDump formats bytes as a traditional hex dump with offset, hex,
|
||||
// and ASCII columns. Each line shows 16 bytes.
|
||||
//
|
||||
// Example output:
|
||||
//
|
||||
// 00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 |Hello World! |
|
||||
func FormatHexDump(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for offset := 0; offset < len(data); offset += 16 {
|
||||
fmt.Fprintf(&sb, "%08x ", offset)
|
||||
|
||||
end := offset + 16
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
chunk := data[offset:end]
|
||||
|
||||
// Hex columns (two groups of eight bytes).
|
||||
for i := 0; i < 16; i++ {
|
||||
if i == 8 {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
if i < len(chunk) {
|
||||
fmt.Fprintf(&sb, "%02x ", chunk[i])
|
||||
} else {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
// ASCII column.
|
||||
sb.WriteString(" |")
|
||||
for _, b := range chunk {
|
||||
if b >= 0x20 && b <= 0x7e {
|
||||
sb.WriteByte(b)
|
||||
} else {
|
||||
sb.WriteByte('.')
|
||||
}
|
||||
}
|
||||
for i := len(chunk); i < 16; i++ {
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
sb.WriteString("|\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ParseEscaped processes C-style escape sequences in a string.
|
||||
// Supported sequences: \r \n \t \\ \0 \xNN
|
||||
func ParseEscaped(s string) ([]byte, error) {
|
||||
var result []byte
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] != '\\' {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if r == utf8.RuneError && size <= 1 {
|
||||
result = append(result, s[i])
|
||||
i++
|
||||
} else {
|
||||
result = append(result, s[i:i+size]...)
|
||||
i += size
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if i+1 >= len(s) {
|
||||
return nil, fmt.Errorf("trailing backslash at position %d", i)
|
||||
}
|
||||
|
||||
next := s[i+1]
|
||||
switch next {
|
||||
case 'r':
|
||||
result = append(result, '\r')
|
||||
i += 2
|
||||
case 'n':
|
||||
result = append(result, '\n')
|
||||
i += 2
|
||||
case 't':
|
||||
result = append(result, '\t')
|
||||
i += 2
|
||||
case '\\':
|
||||
result = append(result, '\\')
|
||||
i += 2
|
||||
case '0':
|
||||
result = append(result, 0)
|
||||
i += 2
|
||||
case 'x', 'X':
|
||||
if i+3 >= len(s) {
|
||||
return nil, fmt.Errorf("incomplete \\x escape at position %d", i)
|
||||
}
|
||||
hexStr := s[i+2 : i+4]
|
||||
b, err := strconv.ParseUint(hexStr, 16, 8)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid \\x escape at position %d: %s", i, hexStr)
|
||||
}
|
||||
result = append(result, byte(b))
|
||||
i += 4
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown escape sequence \\%c at position %d", next, i)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ToDisplayEncoding converts raw bytes to a display string using the
|
||||
// specified encoding. Supported encodings: "utf-8" (default), "ascii",
|
||||
// "iso-8859-1" (also "latin1").
|
||||
func ToDisplayEncoding(data []byte, encoding string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "ascii":
|
||||
return toASCII(data)
|
||||
case "iso-8859-1", "latin1", "latin-1":
|
||||
return toLatin1(data)
|
||||
default:
|
||||
return toUTF8(data)
|
||||
}
|
||||
}
|
||||
|
||||
// toASCII replaces non-ASCII bytes with '.', preserving common control chars.
|
||||
func toASCII(data []byte) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(data))
|
||||
for _, b := range data {
|
||||
if b >= 0x20 && b <= 0x7e {
|
||||
sb.WriteByte(b)
|
||||
} else if b == '\n' || b == '\r' || b == '\t' {
|
||||
sb.WriteByte(b)
|
||||
} else {
|
||||
sb.WriteByte('.')
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// toLatin1 decodes bytes as ISO 8859-1.
|
||||
func toLatin1(data []byte) string {
|
||||
decoded, err := charmap.ISO8859_1.NewDecoder().Bytes(data)
|
||||
if err != nil {
|
||||
return string(data)
|
||||
}
|
||||
return string(decoded)
|
||||
}
|
||||
|
||||
// toUTF8 ensures the output is valid UTF-8, replacing invalid bytes with
|
||||
// the Unicode replacement character.
|
||||
func toUTF8(data []byte) string {
|
||||
if utf8.Valid(data) {
|
||||
return string(data)
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(data))
|
||||
for len(data) > 0 {
|
||||
r, size := utf8.DecodeRune(data)
|
||||
sb.WriteRune(r)
|
||||
data = data[size:]
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// isHexDigit reports whether r is a valid hexadecimal digit.
|
||||
func isHexDigit(r rune) bool {
|
||||
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package rawclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ParseHexString
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseHexString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "contiguous hex for Hello",
|
||||
input: "48656c6c6f",
|
||||
want: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "space-separated hex",
|
||||
input: "48 65 6c 6c 6f",
|
||||
want: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "0x prefix",
|
||||
input: "0x48 0x65 0x6c 0x6c 0x6f",
|
||||
want: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid characters",
|
||||
input: "ZZZZ",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "null bytes",
|
||||
input: "0000",
|
||||
want: []byte{0x00, 0x00},
|
||||
},
|
||||
{
|
||||
name: "0xff bytes",
|
||||
input: "ff ff",
|
||||
want: []byte{0xff, 0xff},
|
||||
},
|
||||
{
|
||||
name: "odd number of digits",
|
||||
input: "abc",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseHexString(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseHexString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && !bytes.Equal(got, tt.want) {
|
||||
t.Errorf("ParseHexString(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ParseEscaped
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseEscaped(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "plain text", input: "hello", want: []byte("hello")},
|
||||
{name: "newline escape", input: `\n`, want: []byte{'\n'}},
|
||||
{name: "carriage return + newline", input: `\r\n`, want: []byte{'\r', '\n'}},
|
||||
{name: "tab escape", input: `\t`, want: []byte{'\t'}},
|
||||
{name: "null byte", input: `\0`, want: []byte{0x00}},
|
||||
{name: "hex escape 0xff", input: `\xff`, want: []byte{0xff}},
|
||||
{name: "escaped backslash", input: `\\`, want: []byte{'\\'}},
|
||||
{name: "mixed AT command", input: `AT\r\n`, want: []byte{'A', 'T', '\r', '\n'}},
|
||||
{name: "hex escape 0x00", input: `\x00`, want: []byte{0x00}},
|
||||
{name: "trailing backslash", input: `hello\`, wantErr: true},
|
||||
{name: "unknown escape", input: `\q`, wantErr: true},
|
||||
{name: "incomplete hex escape", input: `\x0`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseEscaped(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseEscaped(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && !bytes.Equal(got, tt.want) {
|
||||
t.Errorf("ParseEscaped(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FormatHexDump
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFormatHexDump(t *testing.T) {
|
||||
t.Run("empty data", func(t *testing.T) {
|
||||
got := FormatHexDump(nil)
|
||||
if got != "" {
|
||||
t.Errorf("FormatHexDump(nil) = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("small data", func(t *testing.T) {
|
||||
got := FormatHexDump([]byte("Hi"))
|
||||
if !strings.Contains(got, "48 69") {
|
||||
t.Errorf("expected hex for 'Hi', got:\n%s", got)
|
||||
}
|
||||
// Should contain the ASCII display column.
|
||||
if !strings.Contains(got, "|Hi") {
|
||||
t.Errorf("expected ASCII column with 'Hi', got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("16+ bytes spans multiple lines", func(t *testing.T) {
|
||||
data := []byte("ABCDEFGHIJKLMNOPQRST") // 20 bytes
|
||||
got := FormatHexDump(data)
|
||||
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
|
||||
if len(lines) < 2 {
|
||||
t.Errorf("expected at least 2 lines for 20 bytes, got %d", len(lines))
|
||||
}
|
||||
// First line should start with offset 00000000.
|
||||
if !strings.HasPrefix(lines[0], "00000000") {
|
||||
t.Errorf("first line should start with 00000000, got %q", lines[0])
|
||||
}
|
||||
// Second line should start with offset 00000010.
|
||||
if !strings.HasPrefix(lines[1], "00000010") {
|
||||
t.Errorf("second line should start with 00000010, got %q", lines[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FormatHex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFormatHex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
want string
|
||||
}{
|
||||
{name: "empty", input: nil, want: ""},
|
||||
{name: "single byte", input: []byte{0x42}, want: "42"},
|
||||
{name: "Hello", input: []byte("Hello"), want: "48 65 6c 6c 6f"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatHex(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatHex() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToDisplayEncoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestToDisplayEncoding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
encoding string
|
||||
contains string
|
||||
}{
|
||||
{
|
||||
name: "utf-8 passthrough",
|
||||
data: []byte("Hello, World!"),
|
||||
encoding: "utf-8",
|
||||
contains: "Hello, World!",
|
||||
},
|
||||
{
|
||||
name: "ascii replaces non-printable",
|
||||
data: []byte{0x80, 0x41},
|
||||
encoding: "ascii",
|
||||
contains: ".A",
|
||||
},
|
||||
{
|
||||
name: "latin-1 decodes high bytes",
|
||||
data: []byte{0xe9}, // é in ISO 8859-1
|
||||
encoding: "latin-1",
|
||||
contains: "é",
|
||||
},
|
||||
{
|
||||
name: "iso-8859-1 alias",
|
||||
data: []byte{0xe9},
|
||||
encoding: "iso-8859-1",
|
||||
contains: "é",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ToDisplayEncoding(tt.data, tt.encoding)
|
||||
if !strings.Contains(got, tt.contains) {
|
||||
t.Errorf("ToDisplayEncoding() = %q, expected to contain %q", got, tt.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package rawclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
)
|
||||
|
||||
// Client manages a raw TCP connection.
|
||||
// It provides binary-safe data handling with support for text and hex views,
|
||||
// timestamps, direction markers, and payload presets.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
config *models.Profile
|
||||
conn net.Conn
|
||||
connected bool
|
||||
onData func(data []byte, direction string, timestamp time.Time)
|
||||
onError func(err error)
|
||||
onClose func()
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
history []HistoryEntry
|
||||
historyMu sync.RWMutex
|
||||
}
|
||||
|
||||
// HistoryEntry records a single sent or received data frame with its timestamp.
|
||||
type HistoryEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Direction string `json:"direction"` // "send" or "recv"
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
// NewClient creates a new raw TCP client bound to the given profile.
|
||||
func NewClient(profile *models.Profile) *Client {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Client{
|
||||
config: profile,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCallbacks sets the data, error, and close callbacks. Must be called
|
||||
// before Connect.
|
||||
func (c *Client) SetCallbacks(onData func([]byte, string, time.Time), onError func(error), onClose func()) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.onData = onData
|
||||
c.onError = onError
|
||||
c.onClose = onClose
|
||||
}
|
||||
|
||||
// rawConf returns the RAW-specific configuration, or a zero value if nil.
|
||||
func (c *Client) rawConf() models.RAWConfig {
|
||||
if c.config.RAW != nil {
|
||||
return *c.config.RAW
|
||||
}
|
||||
return models.RAWConfig{}
|
||||
}
|
||||
|
||||
// Connect establishes the raw TCP connection and starts the read loop.
|
||||
func (c *Client) Connect() error {
|
||||
c.mu.Lock()
|
||||
if c.connected {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("already connected")
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
timeout := time.Duration(c.config.ConnectTimeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
port := c.config.Port
|
||||
if port == 0 {
|
||||
return fmt.Errorf("port must be specified for raw TCP connections")
|
||||
}
|
||||
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("raw TCP connection to %s failed: %w", addr, err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.connected = true
|
||||
c.mu.Unlock()
|
||||
|
||||
go c.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write sends raw bytes to the connection and records the frame in history.
|
||||
func (c *Client) Write(data []byte) error {
|
||||
c.mu.Lock()
|
||||
if !c.connected || c.conn == nil {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
||||
_, err := conn.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
c.appendHistory(HistoryEntry{
|
||||
Timestamp: now,
|
||||
Direction: "send",
|
||||
Data: copyBytes(data),
|
||||
})
|
||||
|
||||
c.mu.Lock()
|
||||
cb := c.onData
|
||||
c.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(copyBytes(data), "send", now)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteText sends text with the configured line ending appended.
|
||||
func (c *Client) WriteText(text string) error {
|
||||
data := c.applyLineEnding(text)
|
||||
return c.Write(data)
|
||||
}
|
||||
|
||||
// WriteHex sends hex-encoded bytes (e.g. "48656c6c6f" or "48 65 6c 6c 6f").
|
||||
func (c *Client) WriteHex(hexStr string) error {
|
||||
data, err := ParseHexString(hexStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid hex input: %w", err)
|
||||
}
|
||||
return c.Write(data)
|
||||
}
|
||||
|
||||
// WriteEscaped sends text with C-style escape sequences processed
|
||||
// (\r, \n, \t, \x00, etc.).
|
||||
func (c *Client) WriteEscaped(text string) error {
|
||||
data, err := ParseEscaped(text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("escape processing failed: %w", err)
|
||||
}
|
||||
return c.Write(data)
|
||||
}
|
||||
|
||||
// Close shuts down the raw TCP connection and invokes the onClose callback.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
if !c.connected {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.connected = false
|
||||
conn := c.conn
|
||||
onClose := c.onClose
|
||||
c.mu.Unlock()
|
||||
|
||||
c.cancel()
|
||||
|
||||
var err error
|
||||
if conn != nil {
|
||||
err = conn.Close()
|
||||
}
|
||||
if onClose != nil {
|
||||
onClose()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// IsConnected returns the current connection state.
|
||||
func (c *Client) IsConnected() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.connected
|
||||
}
|
||||
|
||||
// GetHistory returns a copy of the session data history.
|
||||
func (c *Client) GetHistory() []HistoryEntry {
|
||||
c.historyMu.RLock()
|
||||
defer c.historyMu.RUnlock()
|
||||
|
||||
out := make([]HistoryEntry, len(c.history))
|
||||
copy(out, c.history)
|
||||
return out
|
||||
}
|
||||
|
||||
// ClearHistory clears the session history buffer.
|
||||
func (c *Client) ClearHistory() {
|
||||
c.historyMu.Lock()
|
||||
defer c.historyMu.Unlock()
|
||||
c.history = nil
|
||||
}
|
||||
|
||||
// SaveCapture writes the session history to a file in JSON format.
|
||||
func (c *Client) SaveCapture(path string) error {
|
||||
entries := c.GetHistory()
|
||||
data, err := json.MarshalIndent(entries, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal capture data: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
// readLoop reads from the connection and dispatches received data to the
|
||||
// onData callback.
|
||||
func (c *Client) readLoop() {
|
||||
buf := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
connected := c.connected
|
||||
c.mu.Unlock()
|
||||
|
||||
if !connected || conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
now := time.Now()
|
||||
data := copyBytes(buf[:n])
|
||||
|
||||
c.appendHistory(HistoryEntry{
|
||||
Timestamp: now,
|
||||
Direction: "recv",
|
||||
Data: data,
|
||||
})
|
||||
|
||||
c.mu.Lock()
|
||||
cb := c.onData
|
||||
c.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(data, "recv", now)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
cb := c.onError
|
||||
c.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(fmt.Errorf("read error: %w", err))
|
||||
}
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// appendHistory records a history entry in a thread-safe manner.
|
||||
func (c *Client) appendHistory(entry HistoryEntry) {
|
||||
c.historyMu.Lock()
|
||||
defer c.historyMu.Unlock()
|
||||
c.history = append(c.history, entry)
|
||||
}
|
||||
|
||||
// applyLineEnding appends the configured line ending to text.
|
||||
func (c *Client) applyLineEnding(text string) []byte {
|
||||
rc := c.rawConf()
|
||||
le := rc.LineEnding
|
||||
if le == "" {
|
||||
le = models.LineEndingCRLF
|
||||
}
|
||||
|
||||
var suffix string
|
||||
switch le {
|
||||
case models.LineEndingCR:
|
||||
suffix = "\r"
|
||||
case models.LineEndingLF:
|
||||
suffix = "\n"
|
||||
case models.LineEndingCRLF:
|
||||
suffix = "\r\n"
|
||||
case models.LineEndingNone:
|
||||
// No suffix.
|
||||
}
|
||||
|
||||
if suffix != "" && !strings.HasSuffix(text, suffix) {
|
||||
text += suffix
|
||||
}
|
||||
return []byte(text)
|
||||
}
|
||||
|
||||
// copyBytes returns an independent copy of a byte slice.
|
||||
func copyBytes(data []byte) []byte {
|
||||
out := make([]byte, len(data))
|
||||
copy(out, data)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user