feat: plugin interfaces + window-agnostic session manager with detach/reattach

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 06:21:07 -04:00
parent a75e21138e
commit 5179f5ab76
7 changed files with 293 additions and 0 deletions

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module github.com/wraith/wraith
go 1.26.1
require github.com/google/uuid v1.6.0

2
go.sum Normal file
View File

@ -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=

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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"`
}