From 5179f5ab7650c2a919c41e3d08310a6ba463ed70 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 06:21:07 -0400 Subject: [PATCH] feat: plugin interfaces + window-agnostic session manager with detach/reattach Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 5 ++ go.sum | 2 + internal/plugin/interfaces.go | 57 +++++++++++++++++++ internal/plugin/registry.go | 47 ++++++++++++++++ internal/session/manager.go | 96 ++++++++++++++++++++++++++++++++ internal/session/manager_test.go | 64 +++++++++++++++++++++ internal/session/session.go | 22 ++++++++ 7 files changed, 293 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/plugin/interfaces.go create mode 100644 internal/plugin/registry.go create mode 100644 internal/session/manager.go create mode 100644 internal/session/manager_test.go create mode 100644 internal/session/session.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6ebc43d --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/wraith/wraith + +go 1.26.1 + +require github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/plugin/interfaces.go b/internal/plugin/interfaces.go new file mode 100644 index 0000000..2235e1c --- /dev/null +++ b/internal/plugin/interfaces.go @@ -0,0 +1,57 @@ +package plugin + +type ProtocolHandler interface { + Name() string + Connect(config map[string]interface{}) (Session, error) + Disconnect(sessionID string) error +} + +type Session interface { + ID() string + Protocol() string + Write(data []byte) error + Close() error +} + +type Importer interface { + Name() string + FileExtensions() []string + Parse(data []byte) (*ImportResult, error) +} + +type ImportResult struct { + Groups []ImportGroup `json:"groups"` + Connections []ImportConnection `json:"connections"` + HostKeys []ImportHostKey `json:"hostKeys"` + Theme *ImportTheme `json:"theme,omitempty"` +} + +type ImportGroup struct { + Name string `json:"name"` + ParentName string `json:"parentName,omitempty"` +} + +type ImportConnection struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + Port int `json:"port"` + Protocol string `json:"protocol"` + Username string `json:"username"` + GroupName string `json:"groupName"` + Notes string `json:"notes"` +} + +type ImportHostKey struct { + Hostname string `json:"hostname"` + Port int `json:"port"` + KeyType string `json:"keyType"` + Fingerprint string `json:"fingerprint"` +} + +type ImportTheme struct { + Name string `json:"name"` + Foreground string `json:"foreground"` + Background string `json:"background"` + Cursor string `json:"cursor"` + Colors [16]string `json:"colors"` +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go new file mode 100644 index 0000000..d42a99f --- /dev/null +++ b/internal/plugin/registry.go @@ -0,0 +1,47 @@ +package plugin + +import "fmt" + +type Registry struct { + protocols map[string]ProtocolHandler + importers map[string]Importer +} + +func NewRegistry() *Registry { + return &Registry{ + protocols: make(map[string]ProtocolHandler), + importers: make(map[string]Importer), + } +} + +func (r *Registry) RegisterProtocol(handler ProtocolHandler) { + r.protocols[handler.Name()] = handler +} + +func (r *Registry) RegisterImporter(imp Importer) { + r.importers[imp.Name()] = imp +} + +func (r *Registry) GetProtocol(name string) (ProtocolHandler, error) { + h, ok := r.protocols[name] + if !ok { + return nil, fmt.Errorf("protocol handler %q not registered", name) + } + return h, nil +} + +func (r *Registry) GetImporter(name string) (Importer, error) { + imp, ok := r.importers[name] + if !ok { + return nil, fmt.Errorf("importer %q not registered", name) + } + return imp, nil +} + +func (r *Registry) ListProtocols() []string { + names := make([]string, 0, len(r.protocols)) + for name := range r.protocols { + names = append(names, name) + } + return names +} diff --git a/internal/session/manager.go b/internal/session/manager.go new file mode 100644 index 0000000..74e760c --- /dev/null +++ b/internal/session/manager.go @@ -0,0 +1,96 @@ +package session + +import ( + "fmt" + "sync" + + "github.com/google/uuid" +) + +const MaxSessions = 32 + +type Manager struct { + mu sync.RWMutex + sessions map[string]*SessionInfo +} + +func NewManager() *Manager { + return &Manager{ + sessions: make(map[string]*SessionInfo), + } +} + +func (m *Manager) Create(connectionID int64, protocol string) (*SessionInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.sessions) >= MaxSessions { + return nil, fmt.Errorf("maximum sessions (%d) reached", MaxSessions) + } + + s := &SessionInfo{ + ID: uuid.NewString(), + ConnectionID: connectionID, + Protocol: protocol, + State: StateConnecting, + TabPosition: len(m.sessions), + } + m.sessions[s.ID] = s + return s, nil +} + +func (m *Manager) Get(id string) (*SessionInfo, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.sessions[id] + return s, ok +} + +func (m *Manager) List() []*SessionInfo { + m.mu.RLock() + defer m.mu.RUnlock() + list := make([]*SessionInfo, 0, len(m.sessions)) + for _, s := range m.sessions { + list = append(list, s) + } + return list +} + +func (m *Manager) SetState(id string, state SessionState) error { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.sessions[id] + if !ok { + return fmt.Errorf("session %s not found", id) + } + s.State = state + return nil +} + +func (m *Manager) Detach(id string) error { + return m.SetState(id, StateDetached) +} + +func (m *Manager) Reattach(id, windowID string) error { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.sessions[id] + if !ok { + return fmt.Errorf("session %s not found", id) + } + s.State = StateConnected + s.WindowID = windowID + return nil +} + +func (m *Manager) Remove(id string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, id) +} + +func (m *Manager) Count() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.sessions) +} diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go new file mode 100644 index 0000000..86b4370 --- /dev/null +++ b/internal/session/manager_test.go @@ -0,0 +1,64 @@ +package session + +import "testing" + +func TestCreateSession(t *testing.T) { + m := NewManager() + s, err := m.Create(1, "ssh") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if s.ID == "" { + t.Error("session ID should not be empty") + } + if s.State != StateConnecting { + t.Errorf("State = %q, want %q", s.State, StateConnecting) + } +} + +func TestMaxSessions(t *testing.T) { + m := NewManager() + for i := 0; i < MaxSessions; i++ { + _, err := m.Create(int64(i), "ssh") + if err != nil { + t.Fatalf("Create() error at %d: %v", i, err) + } + } + _, err := m.Create(999, "ssh") + if err == nil { + t.Error("Create() should fail at max sessions") + } +} + +func TestDetachReattach(t *testing.T) { + m := NewManager() + s, _ := m.Create(1, "ssh") + m.SetState(s.ID, StateConnected) + + if err := m.Detach(s.ID); err != nil { + t.Fatalf("Detach() error: %v", err) + } + + got, _ := m.Get(s.ID) + if got.State != StateDetached { + t.Errorf("State = %q, want %q", got.State, StateDetached) + } + + if err := m.Reattach(s.ID, "window-1"); err != nil { + t.Fatalf("Reattach() error: %v", err) + } + + got, _ = m.Get(s.ID) + if got.State != StateConnected { + t.Errorf("State = %q, want %q", got.State, StateConnected) + } +} + +func TestRemoveSession(t *testing.T) { + m := NewManager() + s, _ := m.Create(1, "ssh") + m.Remove(s.ID) + if m.Count() != 0 { + t.Error("session should have been removed") + } +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..3850231 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,22 @@ +package session + +import "time" + +type SessionState string + +const ( + StateConnecting SessionState = "connecting" + StateConnected SessionState = "connected" + StateDisconnected SessionState = "disconnected" + StateDetached SessionState = "detached" +) + +type SessionInfo struct { + ID string `json:"id"` + ConnectionID int64 `json:"connectionId"` + Protocol string `json:"protocol"` + State SessionState `json:"state"` + WindowID string `json:"windowId"` + TabPosition int `json:"tabPosition"` + ConnectedAt time.Time `json:"connectedAt"` +}