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