Implement connection flow, test connection, and secret prompts
- Change ConnectSession/ConnectQuick to return SessionInfo (not string) - Change UpdateProfile to return (Profile, error) for frontend use - Make session connection asynchronous to avoid Wails event loop deadlock - Implement event-based secret prompt (backend ↔ frontend via requestID) - Add TestConnection API for TCP reachability checks with latency - Add 'Save & Connect' button in profile editor - Wire _testConnection to real backend TCP check - Add btn-accent CSS for the green action button
This commit is contained in:
@@ -50,6 +50,21 @@
|
|||||||
border-color: #3da69f;
|
border-color: #3da69f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Accent — success-tinted action, e.g. "Save & Connect" */
|
||||||
|
.btn-accent {
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
.btn-accent:hover {
|
||||||
|
background: #57a85c;
|
||||||
|
border-color: #57a85c;
|
||||||
|
}
|
||||||
|
.btn-accent:active {
|
||||||
|
background: #4a9a4e;
|
||||||
|
border-color: #4a9a4e;
|
||||||
|
}
|
||||||
|
|
||||||
/* Secondary — subtle border */
|
/* Secondary — subtle border */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -185,12 +185,12 @@ class App {
|
|||||||
payload.message || 'Enter your password or passphrase.',
|
payload.message || 'Enter your password or passphrase.',
|
||||||
);
|
);
|
||||||
window.runtime.EventsEmit('session:secret:response', {
|
window.runtime.EventsEmit('session:secret:response', {
|
||||||
sessionID: payload.sessionID,
|
requestID: payload.requestID,
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
window.runtime.EventsEmit('session:secret:response', {
|
window.runtime.EventsEmit('session:secret:response', {
|
||||||
sessionID: payload.sessionID,
|
requestID: payload.requestID,
|
||||||
value: '',
|
value: '',
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,16 +163,19 @@ class ProfileEditor {
|
|||||||
// ── Footer ──────────────────────────────────────────────
|
// ── Footer ──────────────────────────────────────────────
|
||||||
const footer = createElement('div', 'drawer-footer');
|
const footer = createElement('div', 'drawer-footer');
|
||||||
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
|
const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' });
|
||||||
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test Connection' });
|
const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test' });
|
||||||
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
|
const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' });
|
||||||
|
const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' });
|
||||||
|
|
||||||
cancelBtn.addEventListener('click', () => this.close());
|
cancelBtn.addEventListener('click', () => this.close());
|
||||||
testBtn.addEventListener('click', () => this._testConnection());
|
testBtn.addEventListener('click', () => this._testConnection());
|
||||||
saveBtn.addEventListener('click', () => this._handleSave());
|
saveBtn.addEventListener('click', () => this._handleSave(false));
|
||||||
|
connectBtn.addEventListener('click', () => this._handleSave(true));
|
||||||
|
|
||||||
footer.appendChild(cancelBtn);
|
footer.appendChild(cancelBtn);
|
||||||
footer.appendChild(testBtn);
|
footer.appendChild(testBtn);
|
||||||
footer.appendChild(saveBtn);
|
footer.appendChild(saveBtn);
|
||||||
|
footer.appendChild(connectBtn);
|
||||||
drawer.appendChild(footer);
|
drawer.appendChild(footer);
|
||||||
|
|
||||||
// ── Events ──────────────────────────────────────────────
|
// ── Events ──────────────────────────────────────────────
|
||||||
@@ -807,7 +810,7 @@ class ProfileEditor {
|
|||||||
// ── Actions ──────────────────────────────────────────────────────
|
// ── Actions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
async _handleSave() {
|
async _handleSave(andConnect = false) {
|
||||||
this._collectFormValues();
|
this._collectFormValues();
|
||||||
const errors = this._validate(this._profile);
|
const errors = this._validate(this._profile);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
@@ -816,17 +819,22 @@ class ProfileEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let saved;
|
||||||
if (this._isNew) {
|
if (this._isNew) {
|
||||||
const saved = await window.go.api.Service.CreateProfile(this._profile);
|
saved = await window.go.api.Service.CreateProfile(this._profile);
|
||||||
this.app.state.addProfile(saved);
|
this.app.state.addProfile(saved);
|
||||||
this.app.notifications.success('Profile created');
|
this.app.notifications.success('Profile created');
|
||||||
} else {
|
} else {
|
||||||
const saved = await window.go.api.Service.UpdateProfile(this._profile);
|
saved = await window.go.api.Service.UpdateProfile(this._profile);
|
||||||
this.app.state.updateProfile(saved);
|
this.app.state.updateProfile(saved);
|
||||||
this.app.notifications.success('Profile updated');
|
this.app.notifications.success('Profile updated');
|
||||||
}
|
}
|
||||||
this.app.sidebar.render();
|
this.app.sidebar.render();
|
||||||
this.close();
|
this.close();
|
||||||
|
|
||||||
|
if (andConnect && saved && saved.id) {
|
||||||
|
this.app.connectProfile(saved.id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('Save failed: ' + err);
|
this.app.notifications.error('Save failed: ' + err);
|
||||||
}
|
}
|
||||||
@@ -841,7 +849,16 @@ class ProfileEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.app.notifications.info('Test connection is not yet available.');
|
const host = this._profile.host;
|
||||||
|
const port = this._profile.port;
|
||||||
|
this.app.notifications.info(`Testing connection to ${host}:${port}…`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.go.api.Service.TestConnection(host, port);
|
||||||
|
this.app.notifications.success(`Reachable — ${result.latencyMs}ms latency`);
|
||||||
|
} catch (err) {
|
||||||
|
this.app.notifications.error('Test failed: ' + err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Utilities ────────────────────────────────────────────────────
|
// ── Utilities ────────────────────────────────────────────────────
|
||||||
|
|||||||
Vendored
+7
-3
@@ -7,9 +7,9 @@ export function ClearSessionHistory(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function CloseAllSessions():Promise<void>;
|
export function CloseAllSessions():Promise<void>;
|
||||||
|
|
||||||
export function ConnectQuick(arg1:models.Profile):Promise<string>;
|
export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>;
|
||||||
|
|
||||||
export function ConnectSession(arg1:string):Promise<string>;
|
export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
|
||||||
|
|
||||||
export function CreateProfile(arg1:models.Profile):Promise<models.Profile>;
|
export function CreateProfile(arg1:models.Profile):Promise<models.Profile>;
|
||||||
|
|
||||||
@@ -65,6 +65,8 @@ export function PinSession(arg1:string,arg2:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function ReconnectSession(arg1:string):Promise<void>;
|
export function ReconnectSession(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RegisterSecretResponseListener():Promise<void>;
|
||||||
|
|
||||||
export function RenameSession(arg1:string,arg2:string):Promise<void>;
|
export function RenameSession(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
|
export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise<void>;
|
||||||
@@ -87,9 +89,11 @@ export function SetFavourite(arg1:string,arg2:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
|
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function TestConnection(arg1:string,arg2:number):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function UnlockSecretStore(arg1:string):Promise<void>;
|
export function UnlockSecretStore(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function UpdateProfile(arg1:models.Profile):Promise<void>;
|
export function UpdateProfile(arg1:models.Profile):Promise<models.Profile>;
|
||||||
|
|
||||||
export function WriteSession(arg1:string,arg2:string):Promise<void>;
|
export function WriteSession(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ export function ReconnectSession(arg1) {
|
|||||||
return window['go']['api']['Service']['ReconnectSession'](arg1);
|
return window['go']['api']['Service']['ReconnectSession'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RegisterSecretResponseListener() {
|
||||||
|
return window['go']['api']['Service']['RegisterSecretResponseListener']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RenameSession(arg1, arg2) {
|
export function RenameSession(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['RenameSession'](arg1, arg2);
|
return window['go']['api']['Service']['RenameSession'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -170,6 +174,10 @@ export function SetProfileSecret(arg1, arg2, arg3) {
|
|||||||
return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3);
|
return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestConnection(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function UnlockSecretStore(arg1) {
|
export function UnlockSecretStore(arg1) {
|
||||||
return window['go']['api']['Service']['UnlockSecretStore'](arg1);
|
return window['go']['api']['Service']['UnlockSecretStore'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-15
@@ -7,7 +7,9 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
@@ -33,6 +35,10 @@ type Service struct {
|
|||||||
auditLog *audit.Logger
|
auditLog *audit.Logger
|
||||||
prefs *preferences.Manager
|
prefs *preferences.Manager
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
||||||
|
// secretChans maps request IDs to channels awaiting user secret input.
|
||||||
|
secretMu sync.Mutex
|
||||||
|
secretChans map[string]chan secretResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a Service wired to the given backing components.
|
// NewService creates a Service wired to the given backing components.
|
||||||
@@ -54,9 +60,10 @@ func NewService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetContext stores the Wails runtime context.
|
// SetContext stores the Wails runtime context and registers event listeners.
|
||||||
func (s *Service) SetContext(ctx context.Context) {
|
func (s *Service) SetContext(ctx context.Context) {
|
||||||
s.ctx = ctx
|
s.ctx = ctx
|
||||||
|
s.RegisterSecretResponseListener()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -93,13 +100,13 @@ func (s *Service) GetProfile(id string) (models.Profile, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile persists changes to an existing profile.
|
// UpdateProfile persists changes to an existing profile.
|
||||||
func (s *Service) UpdateProfile(profile models.Profile) error {
|
func (s *Service) UpdateProfile(profile models.Profile) (models.Profile, error) {
|
||||||
profile.UpdatedAt = time.Now()
|
profile.UpdatedAt = time.Now()
|
||||||
if err := s.store.UpdateProfile(&profile); err != nil {
|
if err := s.store.UpdateProfile(&profile); err != nil {
|
||||||
return fmt.Errorf("failed to update profile: %w", err)
|
return models.Profile{}, fmt.Errorf("failed to update profile: %w", err)
|
||||||
}
|
}
|
||||||
s.auditLog.LogProfileUpdate(profile.ID, profile.DisplayName)
|
s.auditLog.LogProfileUpdate(profile.ID, profile.DisplayName)
|
||||||
return nil
|
return profile, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProfile removes a profile and its associated secrets.
|
// DeleteProfile removes a profile and its associated secrets.
|
||||||
@@ -212,16 +219,18 @@ func (s *Service) NewProfile(protocol string) models.Profile {
|
|||||||
// Session operations
|
// Session operations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// ConnectSession loads a profile by ID and opens a session.
|
// ConnectSession loads a profile by ID and starts a session asynchronously.
|
||||||
func (s *Service) ConnectSession(profileID string) (string, error) {
|
// Returns immediately with session info in "connecting" state; the frontend
|
||||||
|
// should listen for session:state events for status updates.
|
||||||
|
func (s *Service) ConnectSession(profileID string) (models.SessionInfo, error) {
|
||||||
profile, err := s.store.GetProfile(profileID)
|
profile, err := s.store.GetProfile(profileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("profile not found: %w", err)
|
return models.SessionInfo{}, fmt.Errorf("profile not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID, err := s.sessions.Connect(profile, s.promptSecret)
|
sessionID, err := s.sessions.Connect(profile, s.promptSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return models.SessionInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last-connected timestamp.
|
// Update last-connected timestamp.
|
||||||
@@ -230,15 +239,27 @@ func (s *Service) ConnectSession(profileID string) (string, error) {
|
|||||||
profile.UpdatedAt = now
|
profile.UpdatedAt = now
|
||||||
_ = s.store.UpdateProfile(profile)
|
_ = s.store.UpdateProfile(profile)
|
||||||
|
|
||||||
return sessionID, nil
|
info := s.sessions.GetSessionInfo(sessionID)
|
||||||
|
if info == nil {
|
||||||
|
return models.SessionInfo{}, fmt.Errorf("session created but info not found")
|
||||||
|
}
|
||||||
|
return *info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectQuick opens a session from an ad-hoc (unsaved) profile.
|
// ConnectQuick opens a session from an ad-hoc (unsaved) profile.
|
||||||
func (s *Service) ConnectQuick(profile models.Profile) (string, error) {
|
func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, error) {
|
||||||
if profile.ID == "" {
|
if profile.ID == "" {
|
||||||
profile.ID = uuid.New().String()
|
profile.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
return s.sessions.Connect(&profile, s.promptSecret)
|
sessionID, err := s.sessions.Connect(&profile, s.promptSecret)
|
||||||
|
if err != nil {
|
||||||
|
return models.SessionInfo{}, err
|
||||||
|
}
|
||||||
|
info := s.sessions.GetSessionInfo(sessionID)
|
||||||
|
if info == nil {
|
||||||
|
return models.SessionInfo{}, fmt.Errorf("session created but info not found")
|
||||||
|
}
|
||||||
|
return *info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisconnectSession closes a session by ID.
|
// DisconnectSession closes a session by ID.
|
||||||
@@ -597,15 +618,118 @@ func (s *Service) GetAppInfo() map[string]string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConnection performs a basic TCP reachability check to the given host and port.
|
||||||
|
// This verifies the network path without establishing a full protocol session.
|
||||||
|
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
||||||
|
if host == "" {
|
||||||
|
return nil, fmt.Errorf("host is required")
|
||||||
|
}
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
return nil, fmt.Errorf("port must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connection to %s failed after %dms: %w", addr, elapsed.Milliseconds(), err)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"latencyMs": elapsed.Milliseconds(),
|
||||||
|
"remoteAddr": addr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// promptSecret is a placeholder secret prompt that returns an error when
|
// promptSecret emits a Wails event to request a secret from the user, then
|
||||||
// called outside of an interactive context. In production this would be
|
// blocks until the frontend responds via the session:secret:response event.
|
||||||
// replaced by a Wails dialog event.
|
// This bridges the synchronous Go SSH auth callback with the asynchronous
|
||||||
|
// frontend prompt UI.
|
||||||
func (s *Service) promptSecret(prompt string) (string, error) {
|
func (s *Service) promptSecret(prompt string) (string, error) {
|
||||||
return "", fmt.Errorf("interactive secret prompt not available: %s", prompt)
|
if s.ctx == nil {
|
||||||
|
return "", fmt.Errorf("application context not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique request ID so we can correlate the response.
|
||||||
|
requestID := uuid.New().String()
|
||||||
|
ch := make(chan secretResponse, 1)
|
||||||
|
|
||||||
|
// Register a one-shot listener for the response.
|
||||||
|
s.secretMu.Lock()
|
||||||
|
if s.secretChans == nil {
|
||||||
|
s.secretChans = make(map[string]chan secretResponse)
|
||||||
|
}
|
||||||
|
s.secretChans[requestID] = ch
|
||||||
|
s.secretMu.Unlock()
|
||||||
|
|
||||||
|
// Emit the prompt event to the frontend.
|
||||||
|
wailsRuntime.EventsEmit(s.ctx, "session:secret:prompt", map[string]interface{}{
|
||||||
|
"requestID": requestID,
|
||||||
|
"message": prompt,
|
||||||
|
"title": "Credentials Required",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Block until we receive a response (with a generous timeout).
|
||||||
|
select {
|
||||||
|
case resp := <-ch:
|
||||||
|
s.secretMu.Lock()
|
||||||
|
delete(s.secretChans, requestID)
|
||||||
|
s.secretMu.Unlock()
|
||||||
|
if resp.cancelled {
|
||||||
|
return "", fmt.Errorf("user cancelled secret prompt")
|
||||||
|
}
|
||||||
|
return resp.value, nil
|
||||||
|
case <-time.After(5 * time.Minute):
|
||||||
|
s.secretMu.Lock()
|
||||||
|
delete(s.secretChans, requestID)
|
||||||
|
s.secretMu.Unlock()
|
||||||
|
return "", fmt.Errorf("secret prompt timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretResponse carries the result of a frontend secret prompt.
|
||||||
|
type secretResponse struct {
|
||||||
|
value string
|
||||||
|
cancelled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterSecretResponseListener sets up the Wails event listener that receives
|
||||||
|
// secrets entered by the user. Must be called after SetContext.
|
||||||
|
func (s *Service) RegisterSecretResponseListener() {
|
||||||
|
if s.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wailsRuntime.EventsOn(s.ctx, "session:secret:response", func(optionalData ...interface{}) {
|
||||||
|
if len(optionalData) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, ok := optionalData[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestID, _ := data["requestID"].(string)
|
||||||
|
value, _ := data["value"].(string)
|
||||||
|
cancelled, _ := data["cancelled"].(bool)
|
||||||
|
|
||||||
|
s.secretMu.Lock()
|
||||||
|
ch, exists := s.secretChans[requestID]
|
||||||
|
s.secretMu.Unlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
ch <- secretResponse{value: value, cancelled: cancelled}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// derefProfiles converts a slice of profile pointers to values.
|
// derefProfiles converts a slice of profile pointers to values.
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
|||||||
"state": string(models.SessionConnecting),
|
"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
|
var err error
|
||||||
switch profile.Protocol {
|
switch profile.Protocol {
|
||||||
case models.ProtocolSSH:
|
case models.ProtocolSSH:
|
||||||
@@ -117,7 +120,7 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
|||||||
"state": string(models.SessionError),
|
"state": string(models.SessionError),
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return "", fmt.Errorf("sessions: connection failed: %w", err)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -131,6 +134,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
|||||||
})
|
})
|
||||||
|
|
||||||
m.auditLog.LogConnect(profile.ID, sessionID, string(profile.Protocol), profile.Host)
|
m.auditLog.LogConnect(profile.ID, sessionID, string(profile.Protocol), profile.Host)
|
||||||
|
}()
|
||||||
|
|
||||||
return sessionID, nil
|
return sessionID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +245,19 @@ func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) {
|
|||||||
return &info, nil
|
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.
|
// ListSessions returns all active session info.
|
||||||
func (m *Manager) ListSessions() []models.SessionInfo {
|
func (m *Manager) ListSessions() []models.SessionInfo {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
Reference in New Issue
Block a user