From 8752ecef2dd1a001c24a20602a61773ad8ab74ee Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Wed, 15 Apr 2026 00:00:00 +0100 Subject: [PATCH] Implement connection flow, test connection, and secret prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/css/components.css | 15 +++ frontend/src/js/app.js | 4 +- frontend/src/js/profiles.js | 29 +++-- frontend/wailsjs/go/api/Service.d.ts | 10 +- frontend/wailsjs/go/api/Service.js | 8 ++ internal/api/api.go | 154 ++++++++++++++++++++++++--- internal/sessions/manager.go | 74 ++++++++----- 7 files changed, 240 insertions(+), 54 deletions(-) diff --git a/frontend/src/css/components.css b/frontend/src/css/components.css index cdfd10d..22ce283 100644 --- a/frontend/src/css/components.css +++ b/frontend/src/css/components.css @@ -50,6 +50,21 @@ 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 */ .btn-secondary { background: transparent; diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 6360cee..8bdfab2 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -185,12 +185,12 @@ class App { payload.message || 'Enter your password or passphrase.', ); window.runtime.EventsEmit('session:secret:response', { - sessionID: payload.sessionID, + requestID: payload.requestID, value, }); } catch (_) { window.runtime.EventsEmit('session:secret:response', { - sessionID: payload.sessionID, + requestID: payload.requestID, value: '', cancelled: true, }); diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index f082da7..03c4e1b 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -163,16 +163,19 @@ class ProfileEditor { // ── Footer ────────────────────────────────────────────── const footer = createElement('div', 'drawer-footer'); 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 connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' }); cancelBtn.addEventListener('click', () => this.close()); 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(testBtn); footer.appendChild(saveBtn); + footer.appendChild(connectBtn); drawer.appendChild(footer); // ── Events ────────────────────────────────────────────── @@ -807,7 +810,7 @@ class ProfileEditor { // ── Actions ────────────────────────────────────────────────────── /** @private */ - async _handleSave() { + async _handleSave(andConnect = false) { this._collectFormValues(); const errors = this._validate(this._profile); if (errors.length > 0) { @@ -816,17 +819,22 @@ class ProfileEditor { } try { + let saved; 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.notifications.success('Profile created'); } 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.notifications.success('Profile updated'); } this.app.sidebar.render(); this.close(); + + if (andConnect && saved && saved.id) { + this.app.connectProfile(saved.id); + } } catch (err) { this.app.notifications.error('Save failed: ' + err); } @@ -841,7 +849,16 @@ class ProfileEditor { 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 ──────────────────────────────────────────────────── diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index 38a33f8..59e7014 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -7,9 +7,9 @@ export function ClearSessionHistory(arg1:string):Promise; export function CloseAllSessions():Promise; -export function ConnectQuick(arg1:models.Profile):Promise; +export function ConnectQuick(arg1:models.Profile):Promise; -export function ConnectSession(arg1:string):Promise; +export function ConnectSession(arg1:string):Promise; export function CreateProfile(arg1:models.Profile):Promise; @@ -65,6 +65,8 @@ export function PinSession(arg1:string,arg2:boolean):Promise; export function ReconnectSession(arg1:string):Promise; +export function RegisterSecretResponseListener():Promise; + export function RenameSession(arg1:string,arg2:string):Promise; export function ResizeSession(arg1:string,arg2:number,arg3:number):Promise; @@ -87,9 +89,11 @@ export function SetFavourite(arg1:string,arg2:boolean):Promise; export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise; +export function TestConnection(arg1:string,arg2:number):Promise>; + export function UnlockSecretStore(arg1:string):Promise; -export function UpdateProfile(arg1:models.Profile):Promise; +export function UpdateProfile(arg1:models.Profile):Promise; export function WriteSession(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index d4d02fd..25414b9 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -126,6 +126,10 @@ export function ReconnectSession(arg1) { return window['go']['api']['Service']['ReconnectSession'](arg1); } +export function RegisterSecretResponseListener() { + return window['go']['api']['Service']['RegisterSecretResponseListener'](); +} + export function 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); } +export function TestConnection(arg1, arg2) { + return window['go']['api']['Service']['TestConnection'](arg1, arg2); +} + export function UnlockSecretStore(arg1) { return window['go']['api']['Service']['UnlockSecretStore'](arg1); } diff --git a/internal/api/api.go b/internal/api/api.go index 7cab43f..2ee1094 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,7 +7,9 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net" "runtime" + "sync" "time" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -33,6 +35,10 @@ type Service struct { auditLog *audit.Logger prefs *preferences.Manager 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. @@ -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) { 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. -func (s *Service) UpdateProfile(profile models.Profile) error { +func (s *Service) UpdateProfile(profile models.Profile) (models.Profile, error) { profile.UpdatedAt = time.Now() 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) - return nil + return profile, nil } // DeleteProfile removes a profile and its associated secrets. @@ -212,16 +219,18 @@ func (s *Service) NewProfile(protocol string) models.Profile { // Session operations // --------------------------------------------------------------------------- -// ConnectSession loads a profile by ID and opens a session. -func (s *Service) ConnectSession(profileID string) (string, error) { +// ConnectSession loads a profile by ID and starts a session asynchronously. +// 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) 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) if err != nil { - return "", err + return models.SessionInfo{}, err } // Update last-connected timestamp. @@ -230,15 +239,27 @@ func (s *Service) ConnectSession(profileID string) (string, error) { profile.UpdatedAt = now _ = 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. -func (s *Service) ConnectQuick(profile models.Profile) (string, error) { +func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, error) { if profile.ID == "" { 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. @@ -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 // --------------------------------------------------------------------------- -// promptSecret is a placeholder secret prompt that returns an error when -// called outside of an interactive context. In production this would be -// replaced by a Wails dialog event. +// promptSecret emits a Wails event to request a secret from the user, then +// blocks until the frontend responds via the session:secret:response event. +// This bridges the synchronous Go SSH auth callback with the asynchronous +// frontend prompt UI. 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. diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 1b4c540..adacd06 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -94,43 +94,48 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string) "state": string(models.SessionConnecting), }) - 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) - default: - err = fmt.Errorf("unsupported protocol %q", profile.Protocol) - } + // 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) + 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 + } - if err != nil { m.mu.Lock() - sess.Info.State = models.SessionError - sess.Info.Error = err.Error() + 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.SessionError), - "error": err.Error(), + "state": string(models.SessionConnected), }) - return "", fmt.Errorf("sessions: connection failed: %w", err) - } - m.mu.Lock() - sess.Info.State = models.SessionConnected - sess.Info.ConnectedAt = time.Now() - m.mu.Unlock() + m.auditLog.LogConnect(profile.ID, sessionID, string(profile.Protocol), profile.Host) + }() - 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 } @@ -240,6 +245,19 @@ func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) { 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()