rs2322tcp/internal/client/application.go

347 lines
8.3 KiB
Go

/*
* ============================================================================
* Projekt.....: rs2322tcp
* Datei.......: internal/client/application.go
* Copyright (C) 2026 Dieter Lang
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Beschreibung:
* Gemeinsame Anwendungsschicht für den rs2322tcp-Client.
*
* Die Anwendungsschicht verbindet die Konfiguration, die Control-Verbindung
* zum Server und die clientseitige Runtime. Sie stellt damit einen einfachen
* Lebenszyklus für die spätere grafische Benutzeroberfläche bereit.
*
* Der technische Client wird bei Start und Reconnect vollständig neu
* initialisiert:
*
* Konfiguration laden
* |
* v
* Lokale virtuelle Ports prüfen
* |
* v
* Server verbinden
* |
* v
* Geräteliste abfragen
* |
* v
* Runtime starten
*
* Ein Reconnect beendet zunächst die bestehende Runtime und Control-
* Verbindung und führt anschließend denselben Startablauf erneut aus.
*
* Die Anwendungsschicht enthält bewusst keine GUI-Logik und keine
* Fyne-Abhängigkeit.
* ============================================================================
*/
package client
import (
"fmt"
"sync"
"git.lang-dieter.de/rs2322tcp/internal/config"
"git.lang-dieter.de/rs2322tcp/internal/transport"
)
///////////////////////////////////////////////////////////////////////////////
// Application
///////////////////////////////////////////////////////////////////////////////
// Application represents one running rs2322tcp client application.
//
// Application owns the current client connection and Runtime. The
// configuration file is the persistent source of the client configuration.
//
// The Application is intended to be used by the command-line entry point
// as well as by the later graphical user interface.
type Application struct {
mu sync.Mutex
configFile string
config *config.ClientConfig
client *Client
runtime *Runtime
devices []transport.RemoteDeviceInfo
runtimeDone chan error
}
// NewApplication creates a new client Application.
//
// The client configuration is loaded immediately. No network connection
// is established. Start must be called before the application connects to
// the server or starts the Runtime.
func NewApplication(configFile string) (*Application, error) {
if configFile == "" {
return nil, fmt.Errorf("configuration file is empty")
}
cfg, err := config.LoadClient(configFile)
if err != nil {
return nil, fmt.Errorf("load client configuration: %w", err)
}
return &Application{
configFile: configFile,
config: cfg,
}, nil
}
///////////////////////////////////////////////////////////////////////////////
// Start
///////////////////////////////////////////////////////////////////////////////
// Start loads the client configuration, validates the locally available
// virtual ports, connects to the server and starts the client Runtime.
//
// The Runtime is started asynchronously because Runtime.Run blocks while
// the configured virtual serial connections are active.
func (a *Application) Start() error {
if a == nil {
return fmt.Errorf("application is nil")
}
a.mu.Lock()
if a.runtime != nil || a.client != nil {
a.mu.Unlock()
return fmt.Errorf("application is already running")
}
a.mu.Unlock()
cfg, err := config.LoadClient(a.configFile)
if err != nil {
return fmt.Errorf("load client configuration: %w", err)
}
// Before connecting to the server, make sure that the virtual ports
// configured in client.json match the locally installed virtual ports.
//
// This check is deliberately performed before New(address), so an
// invalid local virtual-port configuration prevents the client from
// starting at all.
if err := validateLocalVirtualPorts(cfg); err != nil {
return err
}
address := fmt.Sprintf(
"%s:%d",
cfg.Server.Address,
cfg.Server.Port,
)
client, err := New(address)
if err != nil {
return err
}
devices, err := client.GetDevices()
if err != nil {
_ = client.Close()
return fmt.Errorf("get remote devices: %w", err)
}
manager := NewVirtualPortManager(
cfg.VirtualPortRange,
)
runtime, err := NewRuntime(
client,
manager,
)
if err != nil {
_ = client.Close()
return fmt.Errorf("create runtime: %w", err)
}
runtimeDone := make(chan error, 1)
a.mu.Lock()
a.config = cfg
a.client = client
a.runtime = runtime
a.devices = append(
[]transport.RemoteDeviceInfo(nil),
devices...,
)
a.runtimeDone = runtimeDone
a.mu.Unlock()
go func() {
runtimeDone <- runtime.Run(*cfg)
}()
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Reconnect
///////////////////////////////////////////////////////////////////////////////
// Reconnect stops the current client connection and starts it again.
//
// The configuration file is loaded again during Start. This is intentional:
// changes made by the GUI are therefore picked up automatically.
//
// Reconnect is also useful when the server connection has been lost or the
// user explicitly requests a new connection.
func (a *Application) Reconnect() error {
if a == nil {
return fmt.Errorf("application is nil")
}
if err := a.Close(); err != nil {
return fmt.Errorf("close current connection: %w", err)
}
return a.Start()
}
///////////////////////////////////////////////////////////////////////////////
// Close
///////////////////////////////////////////////////////////////////////////////
// Close stops the Runtime and closes the client control connection.
//
// Calling Close more than once is safe.
func (a *Application) Close() error {
if a == nil {
return nil
}
a.mu.Lock()
runtime := a.runtime
client := a.client
a.runtime = nil
a.client = nil
a.devices = nil
a.runtimeDone = nil
a.mu.Unlock()
var firstErr error
if runtime != nil {
if err := runtime.Close(); err != nil {
firstErr = err
}
} else if client != nil {
if err := client.Close(); err != nil {
firstErr = err
}
}
return firstErr
}
///////////////////////////////////////////////////////////////////////////////
// Configuration
///////////////////////////////////////////////////////////////////////////////
// Config returns a copy of the currently loaded client configuration.
//
// The configuration is available after NewApplication, even when the
// server connection has not yet been established.
func (a *Application) Config() *config.ClientConfig {
if a == nil {
return nil
}
a.mu.Lock()
defer a.mu.Unlock()
if a.config == nil {
return nil
}
cfg := *a.config
cfg.VirtualPorts = append(
[]config.VirtualPortConfig(nil),
a.config.VirtualPorts...,
)
return &cfg
}
// SaveConfig saves the supplied client configuration to the configuration
// file used by this Application.
//
// After a successful save the supplied configuration also becomes the
// current in-memory configuration.
//
// The running Runtime is not restarted automatically. A subsequent
// Reconnect or application restart will use the saved configuration.
func (a *Application) SaveConfig(
cfg *config.ClientConfig,
) error {
if a == nil {
return fmt.Errorf("application is nil")
}
if cfg == nil {
return fmt.Errorf("client configuration is nil")
}
if err := config.SaveClient(a.configFile, cfg); err != nil {
return err
}
a.mu.Lock()
a.config = cfg
a.mu.Unlock()
return nil
}
///////////////////////////////////////////////////////////////////////////////
// Devices
///////////////////////////////////////////////////////////////////////////////
// Devices returns a copy of the remote devices received from the server.
//
// The returned slice belongs to the caller and can therefore be modified
// without changing the Application state.
func (a *Application) Devices() []transport.RemoteDeviceInfo {
if a == nil {
return nil
}
a.mu.Lock()
defer a.mu.Unlock()
return append(
[]transport.RemoteDeviceInfo(nil),
a.devices...,
)
}
///////////////////////////////////////////////////////////////////////////////
// Runtime state
///////////////////////////////////////////////////////////////////////////////
// Running reports whether the application currently owns a Runtime and a
// client connection.
func (a *Application) Running() bool {
if a == nil {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
return a.runtime != nil && a.client != nil
}