/* * ============================================================================ * 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 * 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, 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) } 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 } // 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 } /////////////////////////////////////////////////////////////////////////////// // 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 } // 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 }