New protocol type 'github' for browsing repositories via the GitHub REST API. Creates a non-terminal panel showing repos grouped by owner/org, sorted by last commit date (newest first), with search filtering and auto-refresh. Backend: - ProtocolGitHub constant and GitHubConfig/GitHubRepo model structs - New internal/githubclient package (REST API, pagination, token validation) - Session manager connectGitHub() with periodic refresh goroutine - API surface: RefreshGitHubRepos() for on-demand refresh - Token stored via existing credential infrastructure (keychain/vault) Frontend: - GitHub tab in profile editor with API URL and token storage fields - New github.js viewer component (grouped repos, badges, language dots) - github.css with charcoal-themed repo cards and org headers - Protocol colour tokens, tab badge, sidebar indicator for GitHub - Event wiring for session:github:repos and session:github:error - Cleanup on tab close Supports GitHub Enterprise via configurable API base URL.
806 lines
22 KiB
Go
806 lines
22 KiB
Go
// Package sessions orchestrates multiple simultaneous SSH, Telnet, RAW TCP,
|
|
// and GitHub sessions. It coordinates between protocol clients, the storage
|
|
// layer, the secret store, and the Wails runtime for event emission.
|
|
package sessions
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"github.com/copilot-ssh-client/app/internal/audit"
|
|
"github.com/copilot-ssh-client/app/internal/githubclient"
|
|
"github.com/copilot-ssh-client/app/internal/models"
|
|
"github.com/copilot-ssh-client/app/internal/rawclient"
|
|
"github.com/copilot-ssh-client/app/internal/secrets"
|
|
"github.com/copilot-ssh-client/app/internal/sshclient"
|
|
"github.com/copilot-ssh-client/app/internal/storage"
|
|
"github.com/copilot-ssh-client/app/internal/telnetclient"
|
|
)
|
|
|
|
// Session wraps a protocol client with metadata and state.
|
|
type Session struct {
|
|
Info models.SessionInfo
|
|
sshClient *sshclient.Client
|
|
telnetClient *telnetclient.Client
|
|
rawClient *rawclient.Client
|
|
githubClient *githubclient.Client
|
|
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
|
profile *models.Profile
|
|
}
|
|
|
|
// Manager handles the lifecycle of all active sessions.
|
|
// It coordinates between protocol clients, the storage layer,
|
|
// the secret store, and the Wails runtime for event emission.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
sessions map[string]*Session
|
|
store *storage.Store
|
|
secretStore *secrets.SecretStore
|
|
keychainStore *secrets.KeychainStore
|
|
auditLog *audit.Logger
|
|
ctx context.Context // Wails app context for event emission
|
|
}
|
|
|
|
// NewManager creates a Manager wired to the given backing services.
|
|
func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychainStore *secrets.KeychainStore, auditLog *audit.Logger) *Manager {
|
|
return &Manager{
|
|
sessions: make(map[string]*Session),
|
|
store: store,
|
|
secretStore: secretStore,
|
|
keychainStore: keychainStore,
|
|
auditLog: auditLog,
|
|
}
|
|
}
|
|
|
|
// SetContext stores the Wails runtime context for event emission.
|
|
func (m *Manager) SetContext(ctx context.Context) {
|
|
m.ctx = ctx
|
|
}
|
|
|
|
// Connect creates a new session from a profile and connects it.
|
|
// The getSecret callback is used to prompt the user for credentials when
|
|
// required. Returns the session ID on success.
|
|
func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string) (string, error)) (string, error) {
|
|
sessionID := uuid.New().String()
|
|
now := time.Now()
|
|
|
|
info := models.SessionInfo{
|
|
ID: sessionID,
|
|
ProfileID: profile.ID,
|
|
ProfileName: profile.DisplayName,
|
|
Protocol: profile.Protocol,
|
|
Host: profile.Host,
|
|
Port: profile.Port,
|
|
State: models.SessionConnecting,
|
|
TabName: m.createTabName(profile),
|
|
ConnectedAt: now,
|
|
}
|
|
|
|
sess := &Session{
|
|
Info: info,
|
|
profile: profile,
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.sessions[sessionID] = sess
|
|
m.mu.Unlock()
|
|
|
|
m.emitEvent("session:state", map[string]interface{}{
|
|
"sessionID": sessionID,
|
|
"state": string(models.SessionConnecting),
|
|
})
|
|
|
|
// Perform the actual protocol handshake in a goroutine so we do not
|
|
// block the Wails event loop — secret prompts require the UI thread.
|
|
go func() {
|
|
var err error
|
|
switch profile.Protocol {
|
|
case models.ProtocolSSH:
|
|
err = m.connectSSH(sess, getSecret)
|
|
case models.ProtocolTelnet:
|
|
err = m.connectTelnet(sess)
|
|
case models.ProtocolRAW:
|
|
err = m.connectRAW(sess)
|
|
case models.ProtocolGitHub:
|
|
err = m.connectGitHub(sess, getSecret)
|
|
default:
|
|
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
|
}
|
|
|
|
if err != nil {
|
|
m.mu.Lock()
|
|
sess.Info.State = models.SessionError
|
|
sess.Info.Error = err.Error()
|
|
m.mu.Unlock()
|
|
|
|
m.emitEvent("session:state", map[string]interface{}{
|
|
"sessionID": sessionID,
|
|
"state": string(models.SessionError),
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
sess.Info.State = models.SessionConnected
|
|
sess.Info.ConnectedAt = time.Now()
|
|
m.mu.Unlock()
|
|
|
|
m.emitEvent("session:state", map[string]interface{}{
|
|
"sessionID": sessionID,
|
|
"state": string(models.SessionConnected),
|
|
})
|
|
|
|
m.auditLog.LogConnect(profile.ID, sessionID, string(profile.Protocol), profile.Host)
|
|
}()
|
|
|
|
return sessionID, nil
|
|
}
|
|
|
|
// Disconnect closes a specific session.
|
|
func (m *Manager) Disconnect(sessionID string) error {
|
|
m.mu.Lock()
|
|
sess, ok := m.sessions[sessionID]
|
|
if !ok {
|
|
m.mu.Unlock()
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.closeSession(sess)
|
|
return nil
|
|
}
|
|
|
|
// Reconnect closes and re-establishes a session using its original profile.
|
|
func (m *Manager) Reconnect(sessionID string, getSecret func(prompt string) (string, error)) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
if !ok {
|
|
m.mu.RUnlock()
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
profile := sess.profile
|
|
tabName := sess.Info.TabName
|
|
pinned := sess.Info.Pinned
|
|
m.mu.RUnlock()
|
|
|
|
// Close the existing session.
|
|
m.closeSession(sess)
|
|
|
|
// Remove the old entry.
|
|
m.mu.Lock()
|
|
delete(m.sessions, sessionID)
|
|
m.mu.Unlock()
|
|
|
|
// Reconnect with the same profile.
|
|
newID, err := m.Connect(profile, getSecret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Preserve tab name and pin state.
|
|
m.mu.Lock()
|
|
if newSess, ok := m.sessions[newID]; ok {
|
|
newSess.Info.TabName = tabName
|
|
newSess.Info.Pinned = pinned
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// WriteToSession sends data to a session's input.
|
|
func (m *Manager) WriteToSession(sessionID string, data []byte) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
|
|
switch {
|
|
case sess.sshClient != nil:
|
|
return sess.sshClient.Write(data)
|
|
case sess.telnetClient != nil:
|
|
return sess.telnetClient.Write(data)
|
|
case sess.rawClient != nil:
|
|
return sess.rawClient.Write(data)
|
|
}
|
|
return fmt.Errorf("sessions: no active client for session %q", sessionID)
|
|
}
|
|
|
|
// ResizeSession sends a terminal resize to a session.
|
|
func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
|
|
switch {
|
|
case sess.sshClient != nil:
|
|
return sess.sshClient.Resize(cols, rows)
|
|
case sess.telnetClient != nil:
|
|
return sess.telnetClient.Resize(cols, rows)
|
|
}
|
|
// RAW sessions have no concept of terminal size.
|
|
return nil
|
|
}
|
|
|
|
// GetSession returns session info by ID.
|
|
func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
sess, ok := m.sessions[sessionID]
|
|
if !ok {
|
|
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
info := sess.Info
|
|
return &info, nil
|
|
}
|
|
|
|
// GetSessionInfo returns session metadata or nil if the session does not exist.
|
|
func (m *Manager) GetSessionInfo(sessionID string) *models.SessionInfo {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
sess, ok := m.sessions[sessionID]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
info := sess.Info
|
|
return &info
|
|
}
|
|
|
|
// ListSessions returns all active session info.
|
|
func (m *Manager) ListSessions() []models.SessionInfo {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
result := make([]models.SessionInfo, 0, len(m.sessions))
|
|
for _, sess := range m.sessions {
|
|
result = append(result, sess.Info)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// RenameSession changes a session's tab name.
|
|
func (m *Manager) RenameSession(sessionID string, name string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
sess, ok := m.sessions[sessionID]
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
sess.Info.TabName = name
|
|
return nil
|
|
}
|
|
|
|
// PinSession toggles pin status.
|
|
func (m *Manager) PinSession(sessionID string, pinned bool) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
sess, ok := m.sessions[sessionID]
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
sess.Info.Pinned = pinned
|
|
return nil
|
|
}
|
|
|
|
// CloseAll closes every active session.
|
|
func (m *Manager) CloseAll() {
|
|
m.mu.RLock()
|
|
sessions := make([]*Session, 0, len(m.sessions))
|
|
for _, sess := range m.sessions {
|
|
sessions = append(sessions, sess)
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
for _, sess := range sessions {
|
|
m.closeSession(sess)
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.sessions = make(map[string]*Session)
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// GetSessionHistory returns the RAW session history (RAW protocol only).
|
|
func (m *Manager) GetSessionHistory(sessionID string) ([]rawclient.HistoryEntry, error) {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
if sess.rawClient == nil {
|
|
return nil, fmt.Errorf("sessions: session %q is not a RAW connection", sessionID)
|
|
}
|
|
return sess.rawClient.GetHistory(), nil
|
|
}
|
|
|
|
// ClearSessionHistory clears RAW session history.
|
|
func (m *Manager) ClearSessionHistory(sessionID string) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
if sess.rawClient == nil {
|
|
return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID)
|
|
}
|
|
sess.rawClient.ClearHistory()
|
|
return nil
|
|
}
|
|
|
|
// SaveSessionCapture saves RAW session capture to file.
|
|
func (m *Manager) SaveSessionCapture(sessionID string, path string) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
if sess.rawClient == nil {
|
|
return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID)
|
|
}
|
|
return sess.rawClient.SaveCapture(path)
|
|
}
|
|
|
|
// SendBreak sends a Telnet break signal (Telnet only).
|
|
func (m *Manager) SendBreak(sessionID string) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
if sess.telnetClient == nil {
|
|
return fmt.Errorf("sessions: session %q is not a Telnet connection", sessionID)
|
|
}
|
|
return sess.telnetClient.SendBreak()
|
|
}
|
|
|
|
// WriteSessionText sends text with line ending via the RAW client.
|
|
func (m *Manager) WriteSessionText(sessionID string, text string) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
if sess.rawClient == nil {
|
|
return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID)
|
|
}
|
|
return sess.rawClient.WriteText(text)
|
|
}
|
|
|
|
// WriteSessionHex sends hex-encoded data via the RAW client.
|
|
func (m *Manager) WriteSessionHex(sessionID string, hexStr string) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
if sess.rawClient == nil {
|
|
return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID)
|
|
}
|
|
return sess.rawClient.WriteHex(hexStr)
|
|
}
|
|
|
|
// WriteSessionEscaped sends text with C-style escapes via the RAW client.
|
|
func (m *Manager) WriteSessionEscaped(sessionID string, text string) error {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
if sess.rawClient == nil {
|
|
return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID)
|
|
}
|
|
return sess.rawClient.WriteEscaped(text)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal: protocol-specific connection logic
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// connectSSH creates an SSH client, sets callbacks, and connects.
|
|
func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (string, error)) error {
|
|
profile := sess.profile
|
|
|
|
client := sshclient.NewClient(profile)
|
|
client.SetCallbacks(
|
|
// onData — forward terminal output to the frontend.
|
|
func(data []byte) {
|
|
m.emitEvent("session:data", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"data": base64.StdEncoding.EncodeToString(data),
|
|
})
|
|
},
|
|
// onError — report connection errors to the frontend.
|
|
func(err error) {
|
|
m.mu.Lock()
|
|
sess.Info.State = models.SessionError
|
|
sess.Info.Error = err.Error()
|
|
m.mu.Unlock()
|
|
|
|
m.emitEvent("session:state", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"state": string(models.SessionError),
|
|
"error": err.Error(),
|
|
})
|
|
},
|
|
// onClose — handle remote session closure.
|
|
func() {
|
|
m.handleSessionClosed(sess.Info.ID)
|
|
},
|
|
)
|
|
|
|
// Build credential callbacks that respect the profile's secret mode.
|
|
sshConf := profile.SSH
|
|
getPassword := m.buildSecretCallback(profile.ID, "password", sshConf.SecretMode, getSecret)
|
|
getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", sshConf.KeySecretMode, getSecret)
|
|
|
|
if err := client.Connect(getPassword, getPassphrase); err != nil {
|
|
return err
|
|
}
|
|
|
|
sess.sshClient = client
|
|
return nil
|
|
}
|
|
|
|
// connectTelnet creates a Telnet client, sets callbacks, and connects.
|
|
func (m *Manager) connectTelnet(sess *Session) error {
|
|
profile := sess.profile
|
|
|
|
client := telnetclient.NewClient(profile)
|
|
client.SetCallbacks(
|
|
// onData
|
|
func(data []byte) {
|
|
m.emitEvent("session:data", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"data": base64.StdEncoding.EncodeToString(data),
|
|
})
|
|
},
|
|
// onError
|
|
func(err error) {
|
|
m.mu.Lock()
|
|
sess.Info.State = models.SessionError
|
|
sess.Info.Error = err.Error()
|
|
m.mu.Unlock()
|
|
|
|
m.emitEvent("session:state", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"state": string(models.SessionError),
|
|
"error": err.Error(),
|
|
})
|
|
},
|
|
// onClose
|
|
func() {
|
|
m.handleSessionClosed(sess.Info.ID)
|
|
},
|
|
// onDebug
|
|
func(msg string) {
|
|
m.emitEvent("session:debug", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"message": msg,
|
|
})
|
|
},
|
|
)
|
|
|
|
if err := client.Connect(); err != nil {
|
|
return err
|
|
}
|
|
|
|
sess.telnetClient = client
|
|
return nil
|
|
}
|
|
|
|
// connectRAW creates a RAW TCP client, sets callbacks, and connects.
|
|
func (m *Manager) connectRAW(sess *Session) error {
|
|
profile := sess.profile
|
|
|
|
client := rawclient.NewClient(profile)
|
|
client.SetCallbacks(
|
|
// onData — includes direction and timestamp for RAW protocol.
|
|
func(data []byte, direction string, timestamp time.Time) {
|
|
m.emitEvent("session:raw:data", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"data": base64.StdEncoding.EncodeToString(data),
|
|
"direction": direction,
|
|
"timestamp": timestamp.UnixMilli(),
|
|
})
|
|
},
|
|
// onError
|
|
func(err error) {
|
|
m.mu.Lock()
|
|
sess.Info.State = models.SessionError
|
|
sess.Info.Error = err.Error()
|
|
m.mu.Unlock()
|
|
|
|
m.emitEvent("session:state", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"state": string(models.SessionError),
|
|
"error": err.Error(),
|
|
})
|
|
},
|
|
// onClose
|
|
func() {
|
|
m.handleSessionClosed(sess.Info.ID)
|
|
},
|
|
)
|
|
|
|
if err := client.Connect(); err != nil {
|
|
return err
|
|
}
|
|
|
|
sess.rawClient = client
|
|
return nil
|
|
}
|
|
|
|
// connectGitHub validates the token and performs the initial repository fetch.
|
|
// It also starts a background goroutine for periodic refresh.
|
|
func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (string, error)) error {
|
|
profile := sess.profile
|
|
ghConf := profile.GitHub
|
|
if ghConf == nil {
|
|
return fmt.Errorf("GitHub configuration missing on profile %q", profile.ID)
|
|
}
|
|
|
|
// Resolve the access token using the standard secret flow.
|
|
getToken := m.buildSecretCallback(profile.ID, "github-token", ghConf.SecretMode, getSecret)
|
|
token, err := getToken()
|
|
if err != nil {
|
|
return fmt.Errorf("obtaining GitHub token: %w", err)
|
|
}
|
|
if token == "" {
|
|
return fmt.Errorf("GitHub token cannot be empty")
|
|
}
|
|
|
|
client := githubclient.New(ghConf.BaseURL, token)
|
|
|
|
// Validate the token.
|
|
login, err := client.ValidateToken(context.Background())
|
|
if err != nil {
|
|
return fmt.Errorf("GitHub authentication failed: %w", err)
|
|
}
|
|
|
|
sess.githubClient = client
|
|
|
|
// Perform the initial fetch.
|
|
repos, err := client.FetchRepos(context.Background())
|
|
if err != nil {
|
|
return fmt.Errorf("fetching repositories: %w", err)
|
|
}
|
|
|
|
// Emit the initial repo list.
|
|
m.emitEvent("session:github:repos", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"login": login,
|
|
"repos": repos,
|
|
})
|
|
|
|
// Start the periodic refresh goroutine.
|
|
interval := time.Duration(ghConf.RefreshIntervalSecs) * time.Second
|
|
if interval < 60*time.Second {
|
|
interval = 5 * time.Minute
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
sess.githubCancel = cancel
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
updated, fetchErr := client.FetchRepos(ctx)
|
|
if fetchErr != nil {
|
|
m.emitEvent("session:github:error", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"error": fetchErr.Error(),
|
|
})
|
|
continue
|
|
}
|
|
m.emitEvent("session:github:repos", map[string]interface{}{
|
|
"sessionID": sess.Info.ID,
|
|
"login": login,
|
|
"repos": updated,
|
|
})
|
|
}
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// FetchGitHubRepos triggers an on-demand refresh of GitHub repos for a session.
|
|
func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error) {
|
|
m.mu.RLock()
|
|
sess, ok := m.sessions[sessionID]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
|
}
|
|
if sess.githubClient == nil {
|
|
return nil, fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID)
|
|
}
|
|
|
|
repos, err := sess.githubClient.FetchRepos(context.Background())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching repos: %w", err)
|
|
}
|
|
|
|
m.emitEvent("session:github:repos", map[string]interface{}{
|
|
"sessionID": sessionID,
|
|
"repos": repos,
|
|
})
|
|
|
|
return repos, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal: helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// buildSecretCallback returns a func() (string, error) that resolves a
|
|
// credential according to the profile's SecretMode:
|
|
// - "saved": check keychain/vault first, prompt if missing, then persist
|
|
// - "session-only": check an in-memory session cache, prompt once per session
|
|
// - "ask-every-time" (or any other value): always prompt the user
|
|
func (m *Manager) buildSecretCallback(profileID string, secretType string, mode models.SecretMode, getSecret func(prompt string) (string, error)) func() (string, error) {
|
|
secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType)
|
|
|
|
// Session-local cache for "session-only" mode. The closure captures this
|
|
// variable so it survives across multiple calls within the same connection.
|
|
var sessionCached string
|
|
var sessionCachedSet bool
|
|
|
|
return func() (string, error) {
|
|
// ── "saved" mode: try persistent stores first ────────────────────
|
|
if mode == models.SecretModeSaved {
|
|
// Try the OS keychain first.
|
|
if m.keychainStore != nil {
|
|
if val, err := m.keychainStore.GetSecret(secretKey); err == nil && val != "" {
|
|
m.auditLog.LogSecretAccess(profileID, secretType)
|
|
return val, nil
|
|
}
|
|
}
|
|
|
|
// Try the encrypted file store.
|
|
if m.secretStore != nil && m.secretStore.IsUnlocked() {
|
|
if val, err := m.secretStore.GetSecret(secretKey); err == nil && val != "" {
|
|
m.auditLog.LogSecretAccess(profileID, secretType)
|
|
return val, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── "session-only" mode: return cached value if we already asked ─
|
|
if mode == models.SecretModeSessionOnly && sessionCachedSet {
|
|
return sessionCached, nil
|
|
}
|
|
|
|
// ── Prompt the user ──────────────────────────────────────────────
|
|
if getSecret == nil {
|
|
return "", fmt.Errorf("no %s available and no prompt callback provided", secretType)
|
|
}
|
|
|
|
prompt := fmt.Sprintf("Enter %s:", secretType)
|
|
val, err := getSecret(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// ── Persist according to mode ────────────────────────────────────
|
|
switch mode {
|
|
case models.SecretModeSaved:
|
|
// Prefer OS keychain, fall back to encrypted vault.
|
|
stored := false
|
|
if m.keychainStore != nil {
|
|
if sErr := m.keychainStore.SetSecret(secretKey, val); sErr == nil {
|
|
stored = true
|
|
}
|
|
}
|
|
if !stored && m.secretStore != nil && m.secretStore.IsUnlocked() {
|
|
_ = m.secretStore.SetSecret(secretKey, val)
|
|
}
|
|
|
|
case models.SecretModeSessionOnly:
|
|
sessionCached = val
|
|
sessionCachedSet = true
|
|
}
|
|
|
|
return val, nil
|
|
}
|
|
}
|
|
|
|
// closeSession shuts down the protocol client for a session.
|
|
func (m *Manager) closeSession(sess *Session) {
|
|
if sess.sshClient != nil {
|
|
sess.sshClient.Close()
|
|
}
|
|
if sess.telnetClient != nil {
|
|
sess.telnetClient.Close()
|
|
}
|
|
if sess.rawClient != nil {
|
|
sess.rawClient.Close()
|
|
}
|
|
if sess.githubCancel != nil {
|
|
sess.githubCancel()
|
|
}
|
|
|
|
m.mu.Lock()
|
|
sess.Info.State = models.SessionDisconnected
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// handleSessionClosed is called when a protocol client reports closure.
|
|
func (m *Manager) handleSessionClosed(sessionID string) {
|
|
m.mu.Lock()
|
|
sess, ok := m.sessions[sessionID]
|
|
if ok {
|
|
sess.Info.State = models.SessionDisconnected
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.auditLog.LogDisconnect(sessionID)
|
|
|
|
m.emitEvent("session:closed", map[string]interface{}{
|
|
"sessionID": sessionID,
|
|
})
|
|
m.emitEvent("session:state", map[string]interface{}{
|
|
"sessionID": sessionID,
|
|
"state": string(models.SessionDisconnected),
|
|
})
|
|
}
|
|
|
|
// createTabName generates a default tab name from the profile's display name
|
|
// and host.
|
|
func (m *Manager) createTabName(profile *models.Profile) string {
|
|
if profile.DisplayName != "" {
|
|
return profile.DisplayName
|
|
}
|
|
if profile.Username != "" {
|
|
return fmt.Sprintf("%s@%s", profile.Username, profile.Host)
|
|
}
|
|
return profile.Host
|
|
}
|
|
|
|
// emitEvent sends an event to the Wails frontend via the runtime context.
|
|
func (m *Manager) emitEvent(eventName string, data interface{}) {
|
|
if m.ctx == nil {
|
|
return
|
|
}
|
|
wailsRuntime.EventsEmit(m.ctx, eventName, data)
|
|
}
|