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:
parent
a75e21138e
commit
5179f5ab76
5
go.mod
Normal file
5
go.mod
Normal 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
2
go.sum
Normal 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=
|
||||
57
internal/plugin/interfaces.go
Normal file
57
internal/plugin/interfaces.go
Normal 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"`
|
||||
}
|
||||
47
internal/plugin/registry.go
Normal file
47
internal/plugin/registry.go
Normal 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
|
||||
}
|
||||
96
internal/session/manager.go
Normal file
96
internal/session/manager.go
Normal 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)
|
||||
}
|
||||
64
internal/session/manager_test.go
Normal file
64
internal/session/manager_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
22
internal/session/session.go
Normal file
22
internal/session/session.go
Normal 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"`
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user