diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 01f375565..a8b3f41dd 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -90,7 +90,6 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y rpm libarchive-tools # Download ONLY the appropriate backend for this platform - name: Download Linux backend @@ -188,8 +187,6 @@ jobs: electron-app/dist/*.exe electron-app/dist/*.AppImage electron-app/dist/*.deb - electron-app/dist/*.rpm - electron-app/dist/*.pacman electron-app/dist/*.dmg electron-app/dist/*.zip electron-app/dist/*.yml diff --git a/README.md b/README.md index c7bff8ea9..6f2466a18 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Hyperloop Control Station H11 - ![Testing View](https://raw.githubusercontent.com/Hyperloop-UPV/webpage/5c1c827d82d380689856ee61af43da30da22e0fc/src/assets/backgrounds/testing-view.png) + +![Testing View](https://raw.githubusercontent.com/Hyperloop-UPV/webpage/5c1c827d82d380689856ee61af43da30da22e0fc/src/assets/backgrounds/testing-view.png) ## Monorepo usage @@ -20,17 +21,17 @@ Before starting, ensure you have the following installed: Our `pnpm-workspace.yaml` defines the following workspaces: -| Workspace | Language | Description | -| :----------------------------- | :------- | :--------------------------------------------- | -| `testing-view` | TS/React | Web interface for telemetry testing | -| `competition-view` | TS/React | UI for the competition | -| `backend` | Go | Data ingestion and pod communication server | -| `packet-sender` | Rust | Utility for simulating vehicle packets | -| `electron-app` | JS | The main Control Station desktop application | -| `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) | -| `@workspace/core` | TS | Shared business logic and types (frontend-kit) | -| `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) | -| `@workspace/typescript-config` | TS | Common TypeScript configuration (frontend-kit) | +| Workspace | Language | Description | +| :----------------------------- | :------- | :---------------------------------------------------- | +| `testing-view` | TS/React | Web interface for telemetry testing | +| `competition-view` | TS/React | UI for the competition | +| `backend` | Go | Data ingestion and pod communication server | +| `packet-sender` | Rust | Utility for simulating vehicle packets | +| `hyperloop-control-station` | JS | The main Control Station electron desktop application | +| `@workspace/ui` | TS/React | Shared UI component library (frontend-kit) | +| `@workspace/core` | TS | Shared business logic and types (frontend-kit) | +| `@workspace/eslint-config` | ESLint | Common ESLint configuration (frontend-kit) | +| `@workspace/typescript-config` | TS | Common TypeScript configuration (frontend-kit) | --- diff --git a/backend/cmd/config.toml b/backend/cmd/config.toml index 90f742c3b..43a2bf434 100644 --- a/backend/cmd/config.toml +++ b/backend/cmd/config.toml @@ -18,6 +18,7 @@ boards = ["BCU", "BMSL", "HVSCU", "HVSCU-Cabinet", "LCU", "PCU", "VCU", "BLCU"] # ADJ (Architecture Description JSON) Configuration [adj] branch = "main" # Leave blank when using ADJ as a submodule (like this: "") +validate = true # Execute ADJ validator before starting backend # Transport Configuration [transport] @@ -33,6 +34,12 @@ max_retries = 0 # Maximum retries before cycling (0 = infinite retr connection_timeout_ms = 1000 # Connection timeout in milliseconds keep_alive_ms = 1000 # Keep-alive interval in milliseconds +# UDP Configuration +# These settings control the UDP server's buffer sizes and performance characteristics +[udp] +ring_buffer_size = 64 # Size of the ring buffer for incoming packets (number of packets, not bytes) +packet_chan_size = 16 # Size of the channel buffer + # TFTP Configuration [tftp] block_size = 131072 # TFTP block size in bytes (128kB) diff --git a/backend/cmd/dev-config.toml b/backend/cmd/dev-config.toml index 0ea168ec3..1cbac364a 100644 --- a/backend/cmd/dev-config.toml +++ b/backend/cmd/dev-config.toml @@ -19,7 +19,7 @@ boards = ["HVSCU", "HVSCU-Cabinet", "PCU", "LCU", "BCU", "BMSL"] # ADJ (Architecture Description JSON) Configuration [adj] branch = "software" # Leave blank when using ADJ as a submodule (like this: "") - +validate = true # Execute ADJ validator before starting backend # Transport Configuration [transport] @@ -35,6 +35,12 @@ max_retries = 0 # Maximum retries before cycling (0 = infinite re connection_timeout_ms = 999999 # Timeout for the initial connection attempt keep_alive_ms = 0 # Keep-alive interval in milliseconds (0 to disable) +# UDP Configuration +# These settings control the UDP server's buffer sizes and performance characteristics +[udp] +ring_buffer_size = 64 # Size of the ring buffer for incoming packets (number of packets, not bytes) +packet_chan_size = 16 # Size of the channel buffer + # Server Configuration [server.ethernet-view] address = "127.0.0.1:4040" diff --git a/backend/cmd/main.go b/backend/cmd/main.go index b31a17d63..694c26df5 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -9,6 +9,8 @@ import ( "github.com/HyperloopUPV-H8/h9-backend/internal/flags" "github.com/HyperloopUPV-H8/h9-backend/internal/pod_data" "github.com/HyperloopUPV-H8/h9-backend/internal/update_factory" + tracelogger "github.com/HyperloopUPV-H8/h9-backend/pkg/logger/trace" + vehicle_models "github.com/HyperloopUPV-H8/h9-backend/internal/vehicle/models" adj_module "github.com/HyperloopUPV-H8/h9-backend/pkg/adj" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport" @@ -33,7 +35,7 @@ func main() { handleVersionFlag() // Configure trace - traceFile := initTrace(flags.TraceLevel, flags.TraceFile) + traceFile := tracelogger.InitTrace(flags.TraceLevel) if traceFile != nil { defer traceFile.Close() } @@ -49,7 +51,7 @@ func main() { } // <--- ADJ ---> - adj, err := adj_module.NewADJ(config.Adj.Branch) + adj, err := adj_module.NewADJ(config.Adj) if err != nil { trace.Fatal().Err(err).Msg("setting up ADJ") } diff --git a/backend/cmd/setup_transport.go b/backend/cmd/setup_transport.go index 9c9a5b628..82bd10dc9 100644 --- a/backend/cmd/setup_transport.go +++ b/backend/cmd/setup_transport.go @@ -50,7 +50,7 @@ func configureTransport( configureTCPServerTransport(adj, transp) // Start handling network packets using UDP server - configureUDPServerTransport(adj, transp) + configureUDPServerTransport(adj, transp, config) } @@ -133,12 +133,14 @@ func configureTCPServerTransport( func configureUDPServerTransport( adj adj_module.ADJ, transp *transport.Transport, + config config.Config, + ) { trace.Info().Msg("Starting UDP server") - udpServer := udp.NewServer(adj.Info.Addresses[BACKEND], adj.Info.Ports[UDP], &trace.Logger) + udpServer := udp.NewServer(adj.Info.Addresses[BACKEND], adj.Info.Ports[UDP], &trace.Logger, config.UDP.RingBufferSize, config.UDP.PacketChanSize) err := udpServer.Start() if err != nil { - panic("failed to start UDP server: " + err.Error()) + trace.Fatal().Err(err).Msg("failed to start UDP server: " + err.Error()) } go transp.HandleUDPServer(udpServer) } diff --git a/backend/internal/config/config_types.go b/backend/internal/config/config_types.go index 1c0a3a559..ecf78c783 100644 --- a/backend/internal/config/config_types.go +++ b/backend/internal/config/config_types.go @@ -11,7 +11,8 @@ type App struct { } type Adj struct { - Branch string `toml:"branch"` + Branch string `toml:"branch"` + Validate bool `toml:"validate"` } type Transport struct { @@ -35,6 +36,11 @@ type TCP struct { KeepAlive int `toml:"keep_alive_ms"` } +type UDP struct { + RingBufferSize int `toml:"ring_buffer_size"` + PacketChanSize int `toml:"packet_chan_size"` +} + type Logging struct { TimeUnit logger.TimeUnit `toml:"time_unit"` LoggingPath string `toml:"logging_path"` @@ -48,5 +54,6 @@ type Config struct { Transport Transport TFTP TFTP TCP TCP + UDP UDP Logging Logging } diff --git a/backend/internal/flags/flags.go b/backend/internal/flags/flags.go index 1ed66bd67..e4fddbdcf 100644 --- a/backend/internal/flags/flags.go +++ b/backend/internal/flags/flags.go @@ -32,7 +32,6 @@ func Init() { flag.StringVar(&ConfigFile, "config", "config.toml", "path to configuration file") flag.BoolVar(&ConfigAllowUnknown, "config-allow-unknown", false, "allow unknown fields in configuration file") flag.StringVar(&TraceLevel, "trace", "info", "set the trace level (\"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\")") - flag.StringVar(&TraceFile, "log", "", "set the trace log file") flag.StringVar(&CPUProfile, "cpuprofile", "", "write cpu profile to file") flag.BoolVar(&EnableSNTP, "sntp", false, "enables a simple SNTP server on port 123") flag.IntVar(&BlockProfile, "blockprofile", 0, "number of block profiles to include") diff --git a/backend/pkg/adj/adj.go b/backend/pkg/adj/adj.go index 8fcb5add6..81c79ce13 100644 --- a/backend/pkg/adj/adj.go +++ b/backend/pkg/adj/adj.go @@ -6,11 +6,13 @@ import ( "os" "path/filepath" + "github.com/HyperloopUPV-H8/h9-backend/internal/config" "github.com/HyperloopUPV-H8/h9-backend/internal/utils" ) const ( - RepoURL = "https://github.com/Hyperloop-UPV/adj.git" // URL of the ADJ repository + RepoURL = "https://github.com/Hyperloop-UPV/adj.git" // URL of the ADJ repository + ADJValidatorScript = ".github/workflows/scripts/adj-tester/main.py" // Path of ADJ valdiator ) var RepoPath = getRepoPath() @@ -32,8 +34,8 @@ func getRepoPath() string { return absPath + string(filepath.Separator) } -func NewADJ(AdjBranch string) (ADJ, error) { - infoRaw, boardsRaw, err := downloadADJ(AdjBranch) +func NewADJ(AdjSettings config.Adj) (ADJ, error) { + infoRaw, boardsRaw, err := downloadADJ(AdjSettings) if err != nil { return ADJ{}, err } @@ -87,8 +89,16 @@ func NewADJ(AdjBranch string) (ADJ, error) { return adj, nil } -func downloadADJ(AdjBranch string) (json.RawMessage, json.RawMessage, error) { - updateRepo(AdjBranch) +func downloadADJ(AdjSettings config.Adj) (json.RawMessage, json.RawMessage, error) { + updateRepo(AdjSettings.Branch) + + // After downloading adj apply adj validator + + if AdjSettings.Validate { + + Validate() + + } info, err := os.ReadFile(RepoPath + "general_info.json") if err != nil { diff --git a/backend/pkg/adj/git.go b/backend/pkg/adj/git.go index e0700771f..2feb7b6bb 100644 --- a/backend/pkg/adj/git.go +++ b/backend/pkg/adj/git.go @@ -1,10 +1,11 @@ package adj import ( - "log" "os" "path/filepath" + trace "github.com/rs/zerolog/log" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" ) @@ -20,8 +21,10 @@ func updateRepo(AdjBranch string) error { if AdjBranch == "" { // Makes use of user's custom ADJ + trace.Info().Msg("No ADJ branch specified. Using local ADJ.") return nil } else { + trace.Info().Msgf("Updating local ADJ repository to match remote branch '%s'", AdjBranch) cloneOptions := &git.CloneOptions{ URL: RepoURL, ReferenceName: plumbing.NewBranchReferenceName(AdjBranch), @@ -41,7 +44,7 @@ func updateRepo(AdjBranch string) error { _, err = git.PlainClone(tempPath, false, cloneOptions) if err != nil { // If the clone fails, work with the local ADJ - log.Printf("Warning: Could not clone ADJ branch '%s' from remote. Working with local ADJ. Error: %v", AdjBranch, err) + trace.Info().Msgf("Warning: Could not clone ADJ branch '%s' from remote. Working with local ADJ. Error: %v", AdjBranch, err) return nil } diff --git a/backend/pkg/adj/validator.go b/backend/pkg/adj/validator.go new file mode 100644 index 000000000..6bcec139a --- /dev/null +++ b/backend/pkg/adj/validator.go @@ -0,0 +1,75 @@ +package adj + +import ( + "os/exec" + "path" + "strings" + + trace "github.com/rs/zerolog/log" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/logger/validator" +) + +func Validate() { + + trace.Info().Msg("Starting ADJ-Validator") + + // Check for Python interpreter is installed and accessible + pyCmd := pythonCommand() + if pyCmd == "" { + trace.Fatal().Msg("No Python interpreter found in PATH. Please install Python 3 and ensure it's accessible via 'python3', 'python', or 'py' command.") + } + + // Construct the full path to the ADJ validator script + validatorPath := path.Join(RepoPath, ADJValidatorScript) + + trace.Debug().Msgf("Running ADJ Validator using command: %s %s --no-color", pyCmd, validatorPath) + + // Execute the ADJ validator script and capture its output + cmd := exec.Command(pyCmd, validatorPath, "--no-color") + output, err := cmd.CombinedOutput() + + // Log the output of the validator for debugging purposes + validator.LogADJValidatorOutput(output) + + // If the command returns a non-zero exit code, it indicates a validation failure or an error during execution + if err != nil { + + if strings.Contains(string(output), "JSON Validation Script") { + + trace.Fatal().Msg("ADJ Validator failed with error, check the output for details.") + + } + + trace.Fatal().Msgf("Error executing ADJ Validator with command '%s %s'. Ensure that you have installed jsonschema with 'pip install jsonschema==4.25.0' and is accessible in your PATH.", pyCmd, validatorPath) + + } + +} + +// pythonCommand returns the name of a Python interpreter executable +// available in the current system PATH. +// +// It checks a list of common Python command names in order of preference: +// - "python3" (typical on Linux/macOS) +// - "python" (may point to Python 3 on many systems) +// - "py" (Python launcher commonly available on Windows) +// +// For each candidate, exec.LookPath is used to determine whether the +// executable can be found in the PATH. The function returns the first +// command that exists. +// +// If none of the candidates are found, an empty string is returned, +// indicating that no Python interpreter is available. +func pythonCommand() string { + candidates := []string{"python3", "python", "py"} + + for _, c := range candidates { + _, err := exec.LookPath(c) + if err == nil { + return c + } + } + + return "" +} diff --git a/backend/pkg/logger/base/logger.go b/backend/pkg/logger/base/logger.go index 643c992eb..8afaabe60 100644 --- a/backend/pkg/logger/base/logger.go +++ b/backend/pkg/logger/base/logger.go @@ -1,12 +1,13 @@ package loggerbase import ( - "fmt" "os" "path" "sync/atomic" "time" + trace "github.com/rs/zerolog/log" + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" loggerHandler "github.com/HyperloopUPV-H8/h9-backend/pkg/logger" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" @@ -42,33 +43,37 @@ type BaseLogger struct { // Function to start the base logger func (sublogger *BaseLogger) Start() error { if !sublogger.Running.CompareAndSwap(false, true) { - fmt.Println("Logger already running") + trace.Warn().Msg("Logger already running") return nil } sublogger.StartTime = loggerHandler.FormatTimestamp(time.Now()) // Update the start time - fmt.Println("Logger started " + string(sublogger.Name) + ".") + trace.Info().Msg("Logger " + string(sublogger.Name) + " started.") return nil } // Function to create the base file path func (sublogger *BaseLogger) CreateFile(filename string) (*os.File, error) { + return CreateFile(loggerHandler.BasePath, sublogger.Name, filename) +} + +// Create File given a path name of loggerand file name +func CreateFile(basePath string, name abstraction.LoggerName, filename string) (*os.File, error) { - // Includes the direcory specified by the user - baseFilename := path.Join(loggerHandler.BasePath, filename) + baseFilename := path.Join(basePath, filename) err := os.MkdirAll(path.Dir(baseFilename), os.ModePerm) if err != nil { return nil, loggerHandler.ErrCreatingAllDir{ - Name: sublogger.Name, + Name: name, Timestamp: time.Now(), Path: baseFilename, } } - return os.Create(path.Join(baseFilename)) + return os.Create(baseFilename) } // Create a base Logger with default values @@ -88,12 +93,12 @@ func (sublogger *BaseLogger) PullRecord(abstraction.LoggerRequest) (abstraction. // Param templateStop is a function that contains the specific stop actions of each logger func (sublogger *BaseLogger) Stop(templateStop func() error) error { if !sublogger.Running.CompareAndSwap(true, false) { - fmt.Println("Logger already stopped" + string(sublogger.Name) + ".") + trace.Warn().Msg("Logger already stopped" + string(sublogger.Name) + ".") return nil } output := templateStop() - fmt.Println("Logger stopped " + string(sublogger.Name) + ".") + trace.Info().Msg("Logger " + string(sublogger.Name) + " stopped.") return output } diff --git a/backend/pkg/logger/data/logger.go b/backend/pkg/logger/data/logger.go index d7304c0e2..36c97cfda 100644 --- a/backend/pkg/logger/data/logger.go +++ b/backend/pkg/logger/data/logger.go @@ -151,7 +151,6 @@ func (sublogger *Logger) getFile(valueName data.ValueName, board string) (*file. // and filename structure func (sublogger *Logger) createFile(valueName data.ValueName, board string) (*os.File, error) { filename := path.Join( - "logger", loggerHandler.Timestamp.Format(loggerHandler.TimestampFormat), "data", strings.ToUpper(board), diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go index d1e9fd68d..2f607ae07 100644 --- a/backend/pkg/logger/logger.go +++ b/backend/pkg/logger/logger.go @@ -2,6 +2,7 @@ package logger import ( + "path" "sync" "sync/atomic" "time" @@ -37,7 +38,10 @@ var _ abstraction.Logger = &Logger{} // Timestamp is used on subloggers to get the current timestamp for folder or file names var Timestamp = time.Now() -var BasePath = "." +// StartAppTimestamp is the time in which the app was started +var StartAppTimestamp = time.Now() + +var BasePath = path.Join("logger", StartAppTimestamp.Format(TimestampFormat)) func (Logger) HandlerName() string { return HandlerName } @@ -175,5 +179,5 @@ func ConfigureLogger(unit TimeUnit, basePath string) { SetFormatTimestamp(unit) // Update base Path - BasePath = basePath + BasePath = path.Join(basePath, "logger", StartAppTimestamp.Format(TimestampFormat)) } diff --git a/backend/pkg/logger/order/logger.go b/backend/pkg/logger/order/logger.go index 474884aa4..06fd9a494 100644 --- a/backend/pkg/logger/order/logger.go +++ b/backend/pkg/logger/order/logger.go @@ -7,6 +7,7 @@ import ( "time" loggerbase "github.com/HyperloopUPV-H8/h9-backend/pkg/logger/base" + trace "github.com/rs/zerolog/log" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" "github.com/HyperloopUPV-H8/h9-backend/pkg/logger" @@ -40,7 +41,8 @@ func NewLogger() *Logger { func (sublogger *Logger) Start() error { if !sublogger.Running.CompareAndSwap(false, true) { - fmt.Println("Logger already running") + + trace.Warn().Msg("Logger already running") return nil } // Create the file for logging, if the logger was already running @@ -52,13 +54,12 @@ func (sublogger *Logger) Start() error { sublogger.StartTime = logger.FormatTimestamp(time.Now()) // Update the start time sublogger.writer = file.NewCSV(fileRaw) - fmt.Println("Logger started " + string(sublogger.Name) + ".") + trace.Info().Msg("Logger " + string(sublogger.Name) + " started.") return nil } func (sublogger *Logger) createFile() (*os.File, error) { filename := path.Join( - "logger", logger.Timestamp.Format(logger.TimestampFormat), "order", "order.csv", diff --git a/backend/cmd/trace.go b/backend/pkg/logger/trace/trace.go similarity index 76% rename from backend/cmd/trace.go rename to backend/pkg/logger/trace/trace.go index 7c0e61c13..d6b44b876 100644 --- a/backend/cmd/trace.go +++ b/backend/pkg/logger/trace/trace.go @@ -1,17 +1,25 @@ -package main +// Package tracelogger provides initialization and configuration for trace-level logging using zerolog. +package tracelogger import ( "fmt" "os" - "path/filepath" + "path" "strconv" - "time" + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + loggerHandler "github.com/HyperloopUPV-H8/h9-backend/pkg/logger" + + loggerbase "github.com/HyperloopUPV-H8/h9-backend/pkg/logger/base" "github.com/rs/zerolog" trace "github.com/rs/zerolog/log" "github.com/rs/zerolog/pkgerrors" ) +const ( + Trace abstraction.LoggerName = "trace" +) + // traceLevelMap maps human-readable level names to zerolog levels. // Valid keys are: "fatal", "error", "warn", "info", "debug", "trace", and "disabled". var traceLevelMap = map[string]zerolog.Level{ @@ -31,27 +39,21 @@ var traceLevelMap = map[string]zerolog.Level{ // // Parameters: // - traceLevel: human-friendly level name (see traceLevelMap) -// - traceFile: filesystem path where log output will be written // // Returns the opened *os.File for the trace file so the caller can close it later, // or nil if an error occurred while creating the file or if the level is invalid. -func initTrace(traceLevel string, traceFile string) *os.File { - - // If trace file is undefined use user settings - - if traceFile == "" { - configDir, err := os.UserConfigDir() - if err != nil { - // fallback to current directory if user config dir is unavailable - configDir = "." - } - traceDir := filepath.Join(configDir, "hyperloop-control-station") - // Ensure directory exists - _ = os.MkdirAll(traceDir, 0o755) - // Use current time in filename to avoid collisions - timestamp := time.Now().Format("20060102T150405") - traceFile = filepath.Join(traceDir, fmt.Sprintf("trace-%s.json", timestamp)) - } +func InitTrace(traceLevel string) *os.File { + + // Directory of trace + traceDir := loggerHandler.BasePath + + // Use current time in filename to avoid collisions + timestamp := loggerHandler.StartAppTimestamp.Format(loggerHandler.TimestampFormat) + + traceFile := path.Join( + "others", + fmt.Sprintf("trace-%s.jsonl", timestamp), + ) // Format the caller as "file:line" instead of the default format. zerolog.CallerMarshalFunc = func(pc uintptr, file string, line int) string { @@ -68,7 +70,7 @@ func initTrace(traceLevel string, traceFile string) *os.File { consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout} // Try to create/open the file for writing logs. On failure, fall back to console only and exit. - file, err := os.Create(traceFile) + file, err := loggerbase.CreateFile(traceDir, Trace, traceFile) if err != nil { // Keep logger configured to write to console and log the fatal error. trace.Logger = trace.Logger.Output(consoleWriter) diff --git a/backend/pkg/logger/validator/validator.go b/backend/pkg/logger/validator/validator.go new file mode 100644 index 000000000..51eb5e3af --- /dev/null +++ b/backend/pkg/logger/validator/validator.go @@ -0,0 +1,49 @@ +// Package validator provides logging utilities for the ADJ Validator component. +package validator + +import ( + "fmt" + "path" + + "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" + loggerbase "github.com/HyperloopUPV-H8/h9-backend/pkg/logger/base" + + loggerHandler "github.com/HyperloopUPV-H8/h9-backend/pkg/logger" + trace "github.com/rs/zerolog/log" +) + +const ( + Validator abstraction.LoggerName = "adj_validator" +) + +// LogADJValidatorOutput logs in a TXT file the output of the ADJ Validator. +func LogADJValidatorOutput(output []byte) { + + // Directory of trace + traceDir := loggerHandler.BasePath + + // Use current time in filename to avoid collisions + timestamp := loggerHandler.StartAppTimestamp.Format(loggerHandler.TimestampFormat) + + traceFile := path.Join( + "others", + fmt.Sprintf("adj-validator-%s.txt", timestamp), + ) + + // Create file + file, err := loggerbase.CreateFile(traceDir, Validator, traceFile) + if err != nil { + trace.Error().Err(err).Msg("Failed to create ADJ Validator log file") + return + } + defer file.Close() + + // Write the output to the file + if _, err := file.Write(output); err != nil { + trace.Error().Err(err).Msg("Failed to write ADJ Validator output to log file") + return + } + + trace.Debug().Msgf("ADJ Validator output logged to %s", file.Name()) + +} diff --git a/backend/pkg/transport/constructor.go b/backend/pkg/transport/constructor.go index d555f40ef..1966ae4fd 100644 --- a/backend/pkg/transport/constructor.go +++ b/backend/pkg/transport/constructor.go @@ -5,7 +5,6 @@ import ( "sync" "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tftp" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/presentation" "github.com/rs/zerolog" ) @@ -36,12 +35,6 @@ func (transport *Transport) WithEncoder(encoder *presentation.Encoder) *Transpor return transport } -func (transport *Transport) WithTFTP(client *tftp.Client) *Transport { - transport.tftp = client - transport.logger.Trace().Msg("set TFTP") - return transport -} - func (transport *Transport) SetIdTarget(id abstraction.PacketId, target abstraction.TransportTarget) *Transport { transport.idToTarget[id] = target transport.logger.Trace().Uint16("id", uint16(id)).Str("target", string(target)).Msg("set id for target") diff --git a/backend/pkg/transport/network/tftp/.gitignore b/backend/pkg/transport/network/tftp/.gitignore deleted file mode 100644 index 62c74bcbe..000000000 --- a/backend/pkg/transport/network/tftp/.gitignore +++ /dev/null @@ -1 +0,0 @@ -testfile.txt \ No newline at end of file diff --git a/backend/pkg/transport/network/tftp/client.go b/backend/pkg/transport/network/tftp/client.go deleted file mode 100644 index b3baf1425..000000000 --- a/backend/pkg/transport/network/tftp/client.go +++ /dev/null @@ -1,77 +0,0 @@ -package tftp - -import ( - "io" - - "github.com/pin/tftp/v3" -) - -// transferMode is the mode for tftp data transfer, this can be either netascii or binary -type transferMode string - -const ( - // transfer information in binary mode - BinaryMode transferMode = "binary" - // transfer information as text - AsciiMode transferMode = "netascii" -) - -// Client creates a connection with a TFTP server, it handles file uploads and downloads, -// providing a callback to notify of progress -type Client struct { - conn *tftp.Client - onProgress progressCallback -} - -// NewClient creates a new client with the provided options. -// -// addr is the server addres where the client will connect to -func NewClient(addr string, opts ...clientOption) (*Client, error) { - conn, err := tftp.NewClient(addr) - if err != nil { - return nil, err - } - - client := &Client{ - conn: conn, - } - - for _, opt := range opts { - err = opt(client) - if err != nil { - return nil, err - } - } - - return client, nil -} - -// ReadFile reads the file specified with filename from the server. -// The file is written to the output Writer. -func (client *Client) ReadFile(filename string, mode transferMode, output io.Writer) (int64, error) { - recv, err := client.conn.Receive(filename, string(mode)) - if err != nil { - return 0, err - } - - writer := newProgressWriter(filename, client.onProgress, output) - - n, err := recv.WriteTo(writer) - - return n, err -} - -// WriteFile writes the file specified with the filename to the server. -// The file is read from the input Reader. -func (client *Client) WriteFile(filename string, mode transferMode, input io.Reader) (int64, error) { - send, err := client.conn.Send(filename, string(mode)) - if err != nil { - return 0, err - } - - reader := newProgressReader(filename, client.onProgress, input) - - n, err := send.ReadFrom(reader) - - return n, err -} diff --git a/backend/pkg/transport/network/tftp/options.go b/backend/pkg/transport/network/tftp/options.go deleted file mode 100644 index 331b2b838..000000000 --- a/backend/pkg/transport/network/tftp/options.go +++ /dev/null @@ -1,47 +0,0 @@ -package tftp - -import "time" - -// clientOption is an option that can be applied to a Client. -// It might fail and return the reason. -type clientOption func(client *Client) error - -// WithProgressCallback will set the progress callback for the client. -func WithProgressCallback(callback progressCallback) clientOption { - return func(client *Client) error { - client.onProgress = callback - return nil - } -} - -// WithBackoff will specify the backoff algorithm for the client. -func WithBackoff(backoff func(int) time.Duration) clientOption { - return func(client *Client) error { - client.conn.SetBackoff(backoff) - return nil - } -} - -// WithBlockSize will specify the block size for messages exchanged. -func WithBlockSize(size int) clientOption { - return func(client *Client) error { - client.conn.SetBlockSize(size) - return nil - } -} - -// WithRetries will set the max retries before aborting the transfer. -func WithRetries(count int) clientOption { - return func(client *Client) error { - client.conn.SetRetries(count) - return nil - } -} - -// WithTimeout will set the time between retries the client has. -func WithTimeout(timeout time.Duration) clientOption { - return func(client *Client) error { - client.conn.SetTimeout(timeout) - return nil - } -} diff --git a/backend/pkg/transport/network/tftp/progress.go b/backend/pkg/transport/network/tftp/progress.go deleted file mode 100644 index 8f50203d3..000000000 --- a/backend/pkg/transport/network/tftp/progress.go +++ /dev/null @@ -1,64 +0,0 @@ -package tftp - -import "io" - -// progressCallback is a function called when progress reading or writing a file is made -type progressCallback = func(file string, amount int) - -// fileProgress holds information on the file and callback assigned to it -type fileProgress struct { - file string - callback progressCallback -} - -// progressReader holds all the information to notify of progress when reading a file -type progressReader struct { - fileProgress - reader io.Reader -} - -// newProgressReader creates a new progressReader with the provided parameters -func newProgressReader(file string, callback progressCallback, reader io.Reader) progressReader { - return progressReader{ - fileProgress: fileProgress{ - file: file, - callback: callback, - }, - reader: reader, - } -} - -// Read maps the unerlying reader Read method and calls the progress callback after reading -func (progress progressReader) Read(p []byte) (n int, err error) { - n, err = progress.reader.Read(p) - if progress.callback != nil { - progress.callback(progress.file, n) - } - return -} - -// progressWriter holds all the information to notify of progress when writing a file -type progressWriter struct { - fileProgress - writer io.Writer -} - -// newProgressWriter creates a new progressWriter with the provided parameters -func newProgressWriter(file string, callback progressCallback, writer io.Writer) progressWriter { - return progressWriter{ - fileProgress: fileProgress{ - file: file, - callback: callback, - }, - writer: writer, - } -} - -// Write maps the underlying writer Write method and calls the progress callback after writing -func (progress progressWriter) Write(p []byte) (n int, err error) { - n, err = progress.writer.Write(p) - if progress.callback != nil { - progress.callback(progress.file, n) - } - return -} diff --git a/backend/pkg/transport/network/tftp/tftp_test.go b/backend/pkg/transport/network/tftp/tftp_test.go deleted file mode 100644 index dbf2884fe..000000000 --- a/backend/pkg/transport/network/tftp/tftp_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package tftp_test - -import ( - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tftp" - tftpv3 "github.com/pin/tftp/v3" - "io" - "os" - "testing" - "time" -) - -func TestTFTP(t *testing.T) { - serverAddr := "127.0.0.1:3000" - fileName := "testfile" - fileContent := "hello" - - os.Create(fileName) - os.WriteFile(fileName, []byte(fileContent), 0644) - - // Server setup - readerFunc := func(filename string, rf io.ReaderFrom) error { - file, err := os.OpenFile(fileName, os.O_RDONLY, 0644) - if err != nil { - return err - } - defer file.Close() - _, err = rf.ReadFrom(file) - return err - } - - writerFunc := func(filename string, wt io.WriterTo) error { - file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return err - } - defer file.Close() - _, err = wt.WriteTo(file) - return err - } - - server := tftpv3.NewServer(readerFunc, writerFunc) - - go func() { - err := server.ListenAndServe(serverAddr) - if err != nil { - t.Fatalf("Server failed to listen: %v", err) - } - }() - - println("Server listening on", serverAddr) - - time.Sleep(10 * time.Millisecond) - - // Initialize TFTP client - client, err := tftp.NewClient(serverAddr) - if err != nil { - t.Fatalf("Failed to create TFTP client: %v", err) - } - println("Client connected to", serverAddr) - - // Open the file to upload - file, err := os.OpenFile(fileName, os.O_RDONLY, 0644) - - // Write to the server - n, err := client.WriteFile(fileName, tftp.BinaryMode, io.Reader(file)) - if err != nil { - t.Fatalf("Failed to write to server: %v", err) - } - println("Uploaded", n, "bytes") - - file.Close() - - // Read from the server - file, _ = os.OpenFile(fileName, os.O_WRONLY, 0644) - n, err = client.ReadFile(fileName, tftp.BinaryMode, io.Writer(file)) - if err != nil { - t.Fatalf("Failed to read from server: %v", err) - } - println("Downloaded", n, "bytes") - - file.Close() - - // Validate the downloaded data matches what was uploaded - buffer := make([]byte, len(fileContent)) - file, _ = os.OpenFile(fileName, os.O_RDONLY, 0644) - io.ReadFull(file, buffer) - if string(buffer) != fileContent { - t.Errorf("Downloaded content mismatch. Expected: %s, Got: %s", fileContent, string(buffer)) - } - println("Downloaded content matches the uploaded content") - - os.Remove(fileName) -} diff --git a/backend/pkg/transport/network/udp/server.go b/backend/pkg/transport/network/udp/server.go index 6edab4464..f595c7813 100644 --- a/backend/pkg/transport/network/udp/server.go +++ b/backend/pkg/transport/network/udp/server.go @@ -3,6 +3,7 @@ package udp import ( "fmt" "net" + "sync" "time" "github.com/rs/zerolog" @@ -25,17 +26,32 @@ type Server struct { packetsCh chan Packet errorsCh chan error stopCh chan struct{} + stopped bool + + ring []Packet + head int + tail int + count int + ringMutex sync.Mutex + notEmpty *sync.Cond } -func NewServer(address string, port uint16, logger *zerolog.Logger) *Server { - return &Server{ +func NewServer(address string, port uint16, logger *zerolog.Logger, ringBufferSize int, packetChanSize int) *Server { + s := &Server{ address: address, port: port, logger: logger, - packetsCh: make(chan Packet, 1000), + packetsCh: make(chan Packet, packetChanSize), errorsCh: make(chan error, 100), stopCh: make(chan struct{}), } + + s.ring = make([]Packet, ringBufferSize) + s.head = 0 + s.tail = 0 + s.count = 0 + s.notEmpty = sync.NewCond(&s.ringMutex) + return s } func (s *Server) Start() error { @@ -56,6 +72,7 @@ func (s *Server) Start() error { Msg("UDP server started") go s.readLoop() + go s.dispatchLoop() return nil } @@ -69,7 +86,7 @@ func (s *Server) readLoop() { default: // Set read deadline to allow periodic checking of stop channel s.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) - + n, addr, err := s.conn.ReadFromUDP(buffer) if err != nil { // Check if it's a timeout error (expected) @@ -104,11 +121,8 @@ func (s *Server) readLoop() { Int("size", len(payload)). Msg("received UDP packet") - select { - case s.packetsCh <- packet: - default: - s.logger.Warn().Msg("packet channel full, dropping packet") - } + // Push packet to ring buffer + s.push(packet) } } } @@ -122,9 +136,73 @@ func (s *Server) GetErrors() <-chan error { } func (s *Server) Stop() error { + s.ringMutex.Lock() + s.stopped = true close(s.stopCh) + s.notEmpty.Broadcast() // despertar a los que esperan + s.ringMutex.Unlock() + if s.conn != nil { return s.conn.Close() } return nil -} \ No newline at end of file +} + +func (s *Server) push(p Packet) { + + s.ringMutex.Lock() + defer s.ringMutex.Unlock() + + if s.count == len(s.ring) { + s.logger.Debug().Msg("Ring buffer full, overwriting oldest UDP packet") + s.head = (s.head + 1) % len(s.ring) + s.count-- + } + + s.ring[s.tail] = p + s.tail = (s.tail + 1) % len(s.ring) + s.count++ + + s.notEmpty.Signal() +} + +func (s *Server) pop() (Packet, bool) { + + s.ringMutex.Lock() + defer s.ringMutex.Unlock() + + for s.count == 0 && !s.stopped { + s.notEmpty.Wait() + } + + if s.count == 0 && s.stopped { + return Packet{}, false + } + + p := s.ring[s.head] + s.head = (s.head + 1) % len(s.ring) + s.count-- + + return p, true +} + +func (s *Server) dispatchLoop() { + for { + select { + case <-s.stopCh: + return + default: + } + + packet, ok := s.pop() + if !ok { + return + } + + select { + case s.packetsCh <- packet: + case <-s.stopCh: + return + } + } +} diff --git a/backend/pkg/transport/transport.go b/backend/pkg/transport/transport.go index b8950cde2..cac9046c9 100644 --- a/backend/pkg/transport/transport.go +++ b/backend/pkg/transport/transport.go @@ -12,10 +12,10 @@ import ( "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tcp" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tftp" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/udp" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/presentation" + "github.com/pin/tftp/v3" "github.com/rs/zerolog" ) diff --git a/backend/pkg/transport/transport_test.go b/backend/pkg/transport/transport_test.go index effe41745..dcac70d38 100644 --- a/backend/pkg/transport/transport_test.go +++ b/backend/pkg/transport/transport_test.go @@ -15,7 +15,6 @@ import ( "github.com/HyperloopUPV-H8/h9-backend/pkg/abstraction" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tcp" - "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/tftp" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/network/udp" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/packet/data" "github.com/HyperloopUPV-H8/h9-backend/pkg/transport/presentation" @@ -372,17 +371,6 @@ func TestTransport_SetTargetIp(t *testing.T) { } } -func TestWithTFTP(t *testing.T) { - tr := NewTransport(defaultLogger()) - tr.SetAPI(noopTransportAPI{}) - client := &tftp.Client{} - - out := tr.WithTFTP(client) - if out.tftp != client { - t.Fatalf("expected tftp client to be set") - } -} - func TestTransportErrors(t *testing.T) { tests := []struct { err error @@ -955,45 +943,6 @@ func TestHandleServer_AcceptsAndDispatches(t *testing.T) { } } -func TestHandleUDPServer_Dispatches(t *testing.T) { - tr, api := createTestTransport(t) - tr.SetpropagateFault(false) - - port := getAvailableUDPPort(t) - logger := zerolog.Nop() - server := udp.NewServer("127.0.0.1", port, &logger) - if err := server.Start(); err != nil { - t.Fatalf("failed to start UDP server: %v", err) - } - defer server.Stop() - - go tr.HandleUDPServer(server) - - packet := data.NewPacket(100) - packet.SetTimestamp(time.Unix(0, 0)) - buf, err := tr.encoder.Encode(packet) - if err != nil { - t.Fatalf("encode failed: %v", err) - } - defer tr.encoder.ReleaseBuffer(buf) - - conn, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: int(port)}) - if err != nil { - t.Fatalf("failed to dial UDP server: %v", err) - } - defer conn.Close() - - if _, err := conn.Write(buf.Bytes()); err != nil { - t.Fatalf("failed to send UDP packet: %v", err) - } - - if err := waitForCondition(func() bool { - return len(api.GetNotifications()) > 0 - }, 2*time.Second, "Should receive notification from UDP server"); err != nil { - t.Fatal(err) - } -} - func TestHandleConversation_DispatchesAndStopsOnError(t *testing.T) { tr, api := createTestTransport(t) diff --git a/electron-app/.gitignore b/electron-app/.gitignore index 0c9c87a1f..ce4204b7b 100644 --- a/electron-app/.gitignore +++ b/electron-app/.gitignore @@ -81,4 +81,5 @@ coverage # Config and config backups config.toml -config.toml.backup-* \ No newline at end of file +config.toml.backup-* +version.toml \ No newline at end of file diff --git a/electron-app/BUILD.md b/electron-app/BUILD.md index 855ba3ff2..e001a1397 100644 --- a/electron-app/BUILD.md +++ b/electron-app/BUILD.md @@ -1,19 +1,18 @@ # Hyperloop Control Station Build System -The project uses a unified, modular build script (`electron-app/build.mjs`) to handle building the backend (Go), packet sender (Rust), and frontends (React/Vite) for the Electron application. +The project uses a unified, modular build script (`electron-app/build.mjs`) to handle building the backend (Go), and frontends (React/Vite) for the Electron application. ## Prerequisites - **Node.js** & **pnpm** - **Go** (1.21+) -- **Rust/Cargo** (for Packet Sender) ## Basic Usage Run the build script from the `electron-app` directory (or via npm scripts). ```sh -# Build EVERYTHING (Backend, Packet Sender, Frontends) +# Build EVERYTHING (Backend, Frontends) pnpm build # OR @@ -34,9 +33,6 @@ node build.mjs --backend # Build only the Testing View node build.mjs --testing-view - -# Build only the Packet Sender -node build.mjs --packet-sender ``` ## Platform Targeting diff --git a/electron-app/build.mjs b/electron-app/build.mjs index b4dff1753..a15f0a4c5 100644 --- a/electron-app/build.mjs +++ b/electron-app/build.mjs @@ -222,13 +222,21 @@ scriptArgs.forEach((arg) => { }); const specificTargets = Object.keys(CONFIG).filter((key) => - scriptArgs.includes(`--${key}`) + scriptArgs.includes(`--${key}`), ); const targetsToBuild = specificTargets.length > 0 ? specificTargets : Object.keys(CONFIG); // --- Main Execution --- +console.log(` +______ __ ______ _____ ____________ __ +___ / / /____ ________________________ /___________________ __ / / /__ __ \\_ | / / +__ /_/ /__ / / /__ __ \\ _ \\_ ___/_ /_ __ \\ __ \\__ __ \\ _ / / /__ /_/ /_ | / / +_ __ / _ /_/ /__ /_/ / __/ / _ / / /_/ / /_/ /_ /_/ / / /_/ / _ ____/__ |/ / +/_/ /_/ _\\__, / _ .___/\\___//_/ /_/ \\____/\\____/_ .___/ \\____/ /_/ _____/ + /____/ /_/ /_/ `); + logger.header("Hyperloop Control Station Build"); (async () => { @@ -254,7 +262,10 @@ logger.header("Hyperloop Control Station Build"); if (frontendBuilt && !process.env.CI) { logger.info("Finalizing Electron..."); - run("pnpm --filter electron-app install --frozen-lockfile", __dirname); + run( + "pnpm --filter hyperloop-control-station install --frozen-lockfile", + __dirname, + ); } if (allSuccess) { diff --git a/electron-app/main.js b/electron-app/main.js index c468fbbe3..ae651860a 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -36,8 +36,6 @@ if (process.platform === "linux") { // Setup IPC handlers for renderer process communication setupIpcHandlers(); -app.setName("hyperloop-control-station"); - // App lifecycle: wait for Electron to be ready app.whenReady().then(async () => { // Get the screen width and height @@ -59,7 +57,6 @@ app.whenReady().then(async () => { logger.electron.header("Backend process spawned"); } catch (error) { // Start backend already shows these errors - return; } // Create main application window @@ -118,11 +115,11 @@ app.on("window-all-closed", () => { }); // Cleanup before app quits -app.on("before-quit", () => { - // Stop backend process gracefully - stopBackend(); - // Stop packet sender process gracefully - stopPacketSender(); +app.on("before-quit", (e) => { + e.preventDefault(); + Promise.all([stopBackend(), stopPacketSender()]) + .catch((error) => logger.electron.error("Error during shutdown:", error)) + .finally(() => app.exit()); }); // Handle uncaught exceptions globally diff --git a/electron-app/package.json b/electron-app/package.json index c5957648d..2d936c068 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -1,5 +1,5 @@ { - "name": "electron-app", + "name": "hyperloop-control-station", "version": "1.0.0", "description": "Hyperloop UPV Control Station", "main": "main.js", @@ -57,7 +57,7 @@ "owner": "Hyperloop-UPV", "repo": "software" }, - "productName": "Hyperloop-Control-Station", + "productName": "Hyperloop-Ctrl", "directories": { "output": "dist" }, @@ -107,9 +107,7 @@ "linux": { "target": [ "AppImage", - "deb", - "rpm", - "pacman" + "deb" ], "icon": "icons/512x512.png", "category": "Utility", diff --git a/electron-app/preload.js b/electron-app/preload.js index 2bc27909c..5c3d5c023 100644 --- a/electron-app/preload.js +++ b/electron-app/preload.js @@ -37,6 +37,10 @@ contextBridge.exposeInMainWorld("electronAPI", { // Open folder selection dialog selectFolder: () => ipcRenderer.invoke("select-folder"), // Receive log message from backend - onLog: (callback) => - ipcRenderer.on("log", (_event, value) => callback(value)), + onLog: (callback) => { + const listener = (_event, value) => callback(value); + ipcRenderer.removeAllListeners("log"); + ipcRenderer.on("log", listener); + return () => ipcRenderer.removeListener("log", listener); + }, }); diff --git a/electron-app/src/config/__tests__/ConfigManager.initialization.test.js b/electron-app/src/config/__tests__/ConfigManager.initialization.test.js index 23ec77586..013507407 100644 --- a/electron-app/src/config/__tests__/ConfigManager.initialization.test.js +++ b/electron-app/src/config/__tests__/ConfigManager.initialization.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; import fs from "fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ConfigManager } from "../configManager.js"; // Mock fs module @@ -8,6 +8,9 @@ vi.mock("fs"); describe("ConfigManager - Initialization", () => { const templatePath = "/path/to/template.toml"; const userConfigPath = "/path/to/user.toml"; + const versionFilePath = "/path/to/version.toml"; + const appVersion = "1.0.0"; + const appVersionReturnValue = `version = "${appVersion}"`; beforeEach(() => { vi.clearAllMocks(); @@ -15,8 +18,14 @@ describe("ConfigManager - Initialization", () => { it("should create config manager instance", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(manager.userConfigPath).toBe(userConfigPath); expect(manager.templatePath).toBe(templatePath); @@ -28,8 +37,9 @@ describe("ConfigManager - Initialization", () => { return true; }); fs.mkdirSync.mockImplementation(() => {}); + fs.readFileSync.mockReturnValue(appVersionReturnValue); - new ConfigManager(userConfigPath, templatePath); + new ConfigManager(userConfigPath, templatePath, versionFilePath, appVersion); expect(fs.mkdirSync).toHaveBeenCalledWith("/path/to", { recursive: true, @@ -39,17 +49,17 @@ describe("ConfigManager - Initialization", () => { it("should copy template if user config does not exist", () => { fs.existsSync.mockImplementation((path) => { if (path === userConfigPath) return false; - if (path === templatePath) return true; return true; }); fs.copyFileSync.mockImplementation(() => {}); + fs.writeFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - new ConfigManager(userConfigPath, templatePath); + new ConfigManager(userConfigPath, templatePath, versionFilePath, appVersion); expect(fs.copyFileSync).toHaveBeenCalledWith(templatePath, userConfigPath); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Created config from template") + expect.stringContaining("Created config from template"), ); consoleSpy.mockRestore(); @@ -59,7 +69,7 @@ describe("ConfigManager - Initialization", () => { fs.existsSync.mockReturnValue(false); expect(() => { - new ConfigManager(userConfigPath, templatePath); + new ConfigManager(userConfigPath, templatePath, versionFilePath, appVersion); }).toThrow("Template not found"); }); }); diff --git a/electron-app/src/config/__tests__/ConfigManager.read-write.test.js b/electron-app/src/config/__tests__/ConfigManager.read-write.test.js index 9d554f238..5caabd60f 100644 --- a/electron-app/src/config/__tests__/ConfigManager.read-write.test.js +++ b/electron-app/src/config/__tests__/ConfigManager.read-write.test.js @@ -9,6 +9,9 @@ import fs from "fs"; describe("ConfigManager - Read/Write Operations", () => { const templatePath = "/path/to/template.toml"; const userConfigPath = "/path/to/user.toml"; + const versionFilePath = "/path/to/version.toml"; + const appVersion = "1.0.0"; + const appVersionReturnValue = `version = "${appVersion}"`; const mockTomlContent = `# User Config name = "test" enabled = true @@ -23,9 +26,15 @@ host = "localhost"`; describe("read", () => { it("should read and parse TOML config", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const config = manager.read(); expect(config).toHaveProperty("name", "test"); @@ -35,20 +44,32 @@ host = "localhost"`; it("should throw error on invalid TOML", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue("invalid toml [[["); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.read()).toThrow("Failed to read config"); }); it("should throw error on file read failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockImplementation(() => { throw new Error("Permission denied"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.read()).toThrow("Failed to read config"); }); @@ -57,9 +78,15 @@ host = "localhost"`; describe("readRaw", () => { it("should return raw TOML content", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const raw = manager.readRaw(); expect(raw).toBe(mockTomlContent); @@ -70,11 +97,17 @@ host = "localhost"`; describe("update", () => { it("should update config from object", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); fs.writeFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const result = manager.update({ name: "updated", enabled: false, @@ -91,12 +124,18 @@ host = "localhost"`; it("should throw error on update failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); fs.writeFileSync.mockImplementation(() => { throw new Error("Write failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.update({ name: "test" })).toThrow( "Failed to update config" @@ -107,11 +146,17 @@ host = "localhost"`; describe("updateValue", () => { it("should update a single value", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); fs.writeFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const result = manager.updateValue("database", "host", "192.168.1.1"); expect(result).toBe(true); @@ -125,11 +170,17 @@ host = "localhost"`; it("should throw error on value update failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockImplementation(() => { throw new Error("Read failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.updateValue("section", "key", "value")).toThrow( "Failed to update value" diff --git a/electron-app/src/config/__tests__/ConfigManager.utilities.test.js b/electron-app/src/config/__tests__/ConfigManager.utilities.test.js index f5b00ea0d..90e49fa8b 100644 --- a/electron-app/src/config/__tests__/ConfigManager.utilities.test.js +++ b/electron-app/src/config/__tests__/ConfigManager.utilities.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; import fs from "fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { ConfigManager } from "../configManager.js"; // Mock fs module @@ -8,6 +8,9 @@ vi.mock("fs"); describe("ConfigManager - Utility Methods", () => { const templatePath = "/path/to/template.toml"; const userConfigPath = "/path/to/user.toml"; + const versionFilePath = "/path/to/version.toml"; + const appVersion = "1.0.0"; + const appVersionReturnValue = `version = "${appVersion}"`; const mockTomlContent = `# User Config name = "test" enabled = true @@ -22,19 +25,26 @@ host = "localhost"`; describe("resetToTemplate", () => { it("should reset config to template", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const result = manager.resetToTemplate(); expect(result).toBe(true); expect(fs.copyFileSync).toHaveBeenCalledWith( templatePath, - userConfigPath + userConfigPath, ); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Config reset to template") + expect.stringContaining("Config reset to template"), ); consoleSpy.mockRestore(); @@ -45,19 +55,31 @@ host = "localhost"`; if (path === templatePath) return false; return true; }); + fs.readFileSync.mockReturnValue(appVersionReturnValue); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.resetToTemplate()).toThrow("Failed to reset config"); }); it("should throw error on copy failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => { throw new Error("Copy failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.resetToTemplate()).toThrow("Failed to reset config"); }); @@ -66,16 +88,22 @@ host = "localhost"`; describe("backup", () => { it("should create backup with timestamp", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => {}); const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); const backupPath = manager.backup(); expect(backupPath).toContain(".backup-"); expect(fs.copyFileSync).toHaveBeenCalledWith(userConfigPath, backupPath); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("Config backed up to") + expect.stringContaining("Config backed up to"), ); consoleSpy.mockRestore(); @@ -83,20 +111,33 @@ host = "localhost"`; it("should throw error on backup failure", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => { throw new Error("Backup failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); expect(() => manager.backup()).toThrow("Failed to backup config"); }); it("should generate unique backup names", async () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(appVersionReturnValue); fs.copyFileSync.mockImplementation(() => {}); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const backup1 = manager.backup(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -111,9 +152,16 @@ host = "localhost"`; describe("validate", () => { it("should return valid for correct TOML", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue(mockTomlContent); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const result = manager.validate(); expect(result).toEqual({ valid: true }); @@ -121,9 +169,16 @@ host = "localhost"`; it("should return error for invalid TOML", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockReturnValue("invalid [[["); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const result = manager.validate(); expect(result.valid).toBe(false); @@ -132,11 +187,18 @@ host = "localhost"`; it("should handle file read errors during validation", () => { fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValueOnce(appVersionReturnValue); fs.readFileSync.mockImplementation(() => { throw new Error("Read failed"); }); - const manager = new ConfigManager(userConfigPath, templatePath); + const manager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + appVersion, + ); + const result = manager.validate(); expect(result.valid).toBe(false); diff --git a/electron-app/src/config/__tests__/updateTomlValue.test.js b/electron-app/src/config/__tests__/updateTomlValue.test.js index 24e400934..a7aa92e54 100644 --- a/electron-app/src/config/__tests__/updateTomlValue.test.js +++ b/electron-app/src/config/__tests__/updateTomlValue.test.js @@ -164,4 +164,45 @@ key2 = "value2"`; 0 ); }); + + it("should not corrupt a [section]-like line inside a multiline string", () => { + const toml = `[app] +note = """ +[not-a-section] +just text +""" +name = "old"`; + + const result = updateTomlValue(toml, "app", "name", "new"); + + expect(result).toContain('name = "new"'); + expect(result).toContain("[not-a-section]"); + }); + + it("should not update a key inside a multiline string", () => { + const toml = `[app] +note = """ +name = "inside multiline" +""" +name = "real"`; + + const result = updateTomlValue(toml, "app", "name", "updated"); + + expect(result).toContain('name = "updated"'); + expect(result).toContain('name = "inside multiline"'); + expect(result.indexOf('name = "inside multiline"')).toBeLessThan( + result.indexOf('name = "updated"') + ); + }); + + it("should handle a multiline string that opens and closes on the same line", () => { + const toml = `[app] +note = """single line multiline""" +name = "old"`; + + const result = updateTomlValue(toml, "app", "name", "new"); + + expect(result).toContain('name = "new"'); + expect(result).toContain('note = """single line multiline"""'); + }); }); diff --git a/electron-app/src/config/configInstance.js b/electron-app/src/config/configInstance.js index ae8068252..33467fb80 100644 --- a/electron-app/src/config/configInstance.js +++ b/electron-app/src/config/configInstance.js @@ -4,8 +4,13 @@ * Provides async wrappers for ConfigManager operations with lazy initialization. */ +import { app } from "electron"; import { logger } from "../utils/logger.js"; -import { getTemplatePath, getUserConfigPath } from "../utils/paths.js"; +import { + getTemplatePath, + getUserConfigPath, + getVersionFilePath, +} from "../utils/paths.js"; // Store the singleton ConfigManager instance let configManager = null; @@ -26,11 +31,19 @@ async function getConfigManager() { // Get paths for user config and template const userConfigPath = getUserConfigPath(); const templatePath = getTemplatePath(); + const versionFilePath = getVersionFilePath(); // Create new ConfigManager instance - configManager = new ConfigManager(userConfigPath, templatePath); + configManager = new ConfigManager( + userConfigPath, + templatePath, + versionFilePath, + app.getVersion(), + ); + logger.config.info("ConfigManager initialized"); logger.config.path("User config", userConfigPath); + logger.config.path("User version config", versionFilePath); logger.config.path("Template path", templatePath); } diff --git a/electron-app/src/config/configManager.js b/electron-app/src/config/configManager.js index adfeed3b9..422fa3c3e 100644 --- a/electron-app/src/config/configManager.js +++ b/electron-app/src/config/configManager.js @@ -4,9 +4,9 @@ * Handles reading, writing, and updating configuration files while maintaining formatting and comments. */ +import TOML from "@iarna/toml"; import fs from "fs"; import path from "path"; -import TOML from "@iarna/toml"; import { logger } from "../utils/logger.js"; /** @@ -21,18 +21,30 @@ import { logger } from "../utils/logger.js"; * const updated = updateTomlValue(content, "database", "host", "192.168.1.1"); */ function updateTomlValue(tomlContent, section, key, newValue) { + const lineEnding = tomlContent.includes("\r\n") ? "\r\n" : "\n"; // Split content into lines for processing const lines = tomlContent.split(/\r?\n/); // Track current section while iterating let currentSection = null; // Flag to track if update was successful let updated = false; + // Track if we're inside a multiline string + let inMultilineString = false; // Process each line const result = lines.map((line) => { // Get trimmed version for parsing const trimmed = line.trim(); + // Track multiline string boundaries (""" or ''') + const tripleDoubleQuotes = (line.match(/"""/g) || []).length; + const tripleSingleQuotes = (line.match(/'''/g) || []).length; + if (tripleDoubleQuotes % 2 !== 0 || tripleSingleQuotes % 2 !== 0) { + inMultilineString = !inMultilineString; + return line; + } + if (inMultilineString) return line; + // Track current section const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); if (sectionMatch) { @@ -57,7 +69,7 @@ function updateTomlValue(tomlContent, section, key, newValue) { // Parse the line: key = value # comment const match = line.match( - /^(\s*)([a-zA-Z_][a-zA-Z0-9_-]*)(\s*=\s*)([^#]+?)((?:\s*#.*)?)$/ + /^(\s*)([a-zA-Z_][a-zA-Z0-9_-]*)(\s*=\s*)([^#]+?)((?:\s*#.*)?)$/, ); // Check if this line matches the key we're looking for @@ -77,7 +89,7 @@ function updateTomlValue(tomlContent, section, key, newValue) { } else if (Array.isArray(newValue)) { // Simple array formatting const items = newValue.map((v) => - typeof v === "string" ? `"${v}"` : v + typeof v === "string" ? `"${v}"` : v, ); formattedValue = `[${items.join(", ")}]`; } else if (newValue === null || newValue === undefined) { @@ -99,12 +111,12 @@ function updateTomlValue(tomlContent, section, key, newValue) { // Warn if key was not found if (!updated) { console.warn( - `Warning: Key "${key}" in section "${section || "root"}" not found` + `Warning: Key "${key}" in section "${section || "root"}" not found`, ); } // Join lines back into string - return result.join("\n"); + return result.join(lineEnding); } /** @@ -153,13 +165,17 @@ class ConfigManager { * Creates a new ConfigManager instance. * @param {string} userConfigPath - Path to the user configuration file. * @param {string} templatePath - Path to the template configuration file. + * @param {string} versionFilePath - Path to the version.toml (app version) + * @param {string} appVersion - Current electron bundle version from package.json * @example - * const manager = new ConfigManager("/path/to/config.toml", "/path/to/template.toml"); + * const manager = new ConfigManager("/path/to/config.toml", "/path/to/template.toml", "/path/to/version.toml" app.getVersion()); */ - constructor(userConfigPath, templatePath) { + constructor(userConfigPath, templatePath, versionFilePath, appVersion) { // Store paths this.userConfigPath = userConfigPath; this.templatePath = templatePath; + this.versionFilePath = versionFilePath; + this.appVersion = appVersion; // Ensure user config exists (copy from template on first run) this.ensureConfigExists(); @@ -180,16 +196,44 @@ class ConfigManager { // Copy template if user config doesn't exist if (!fs.existsSync(this.userConfigPath)) { - if (fs.existsSync(this.templatePath)) { - // Copy template to user config location - fs.copyFileSync(this.templatePath, this.userConfigPath); - logger.config.info( - `Created config from template: ${this.userConfigPath}` - ); - } else { - // Throw error if template is missing + if (!fs.existsSync(this.templatePath)) { throw new Error(`Template not found: ${this.templatePath}`); } + + // Copy template to user config location + fs.copyFileSync(this.templatePath, this.userConfigPath); + logger.config.info( + `Created config from template: ${this.userConfigPath}`, + ); + + fs.writeFileSync( + this.versionFilePath, + `version = "${this.appVersion}"`, + "utf-8", + ); + logger.config.info(`Created app version file: ${this.versionFilePath}`); + return; + } + + // If config does exist, get app's version + // In case version.toml doesn't exists it returns null + const storedVersion = fs.existsSync(this.versionFilePath) + ? (fs + .readFileSync(this.versionFilePath, "utf-8") + .trim() + .match(/^version\s*=\s*"(.+)"$/)?.[1] ?? null) + : null; + + if (storedVersion !== this.appVersion) { + fs.copyFileSync(this.templatePath, this.userConfigPath); + fs.writeFileSync( + this.versionFilePath, + `version = "${this.appVersion}"`, + "utf-8", + ); + logger.config.info( + `Config updated from template (from version ${storedVersion ?? "unknown"} to ${this.appVersion})`, + ); } } @@ -348,4 +392,4 @@ class ConfigManager { } } -export { ConfigManager, updateTomlValue, updateTomlFromObject }; +export { ConfigManager, updateTomlFromObject, updateTomlValue }; diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 3c2c9720d..92dffe7eb 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -54,13 +54,13 @@ async function startBackend(logWindow = null) { logger.backend.error(`Backend binary not found: ${backendBin}`); dialog.showErrorBox( "Error", - `Backend binary not found at: ${backendBin}` + `Backend binary not found at: ${backendBin}`, ); return reject(new Error(`Backend binary not found: ${backendBin}`)); } logger.backend.info( - `Starting backend: ${backendBin}, config: ${configPath}` + `Starting backend: ${backendBin}, config: ${configPath}`, ); // Set working directory to backend/cmd in development, or resources in production @@ -88,7 +88,6 @@ async function startBackend(logWindow = null) { backendProcess.stderr.on("data", (data) => { const errorMsg = data.toString().trim(); logger.backend.error(errorMsg); - // Store the last error message lastBackendError = errorMsg; // Send error message to log window @@ -103,22 +102,17 @@ async function startBackend(logWindow = null) { logger.backend.error(`Failed to start backend: ${error.message}`); dialog.showErrorBox( "Backend Error", - `Failed to start backend: ${error.message}` + `Failed to start backend: ${error.message}`, ); return reject(new Error(`Failed to start backend: ${error.message}`)); }); - // If the backend didn't fail in this period of time, resolve the promise - setTimeout(() => { - resolve(backendProcess); - }, 2000); - // Handle process exit backendProcess.on("close", (code) => { logger.backend.info(`Backend process exited with code ${code}`); - // Show error dialog if process crashed (non-zero exit code) + clearTimeout(startupTimer); + if (code !== 0 && code !== null) { - // Build error message with actual error details let errorMessage = `Backend exited with code ${code}`; if (lastBackendError) { @@ -128,10 +122,18 @@ async function startBackend(logWindow = null) { } dialog.showErrorBox("Backend Crashed", errorMessage); - // Clear error message after showing lastBackendError = null; + backendProcess = null; + return reject(new Error(errorMessage)); } + + backendProcess = null; }); + + // If the backend didn't fail in this period of time, resolve the promise + const startupTimer = setTimeout(() => { + resolve(backendProcess); + }, 2000); }); } @@ -164,7 +166,7 @@ async function stopBackend() { const fallbackTimer = setTimeout(() => { if (localBackendProcess && !localBackendProcess.killed) { logger.backend.warning( - "Backend did not exit gracefully, force killing..." + "Backend did not exit gracefully, force killing...", ); localBackendProcess.kill("SIGKILL"); } diff --git a/electron-app/src/utils/paths.js b/electron-app/src/utils/paths.js index 1d44ceb39..e6ba2b5ca 100644 --- a/electron-app/src/utils/paths.js +++ b/electron-app/src/utils/paths.js @@ -59,14 +59,14 @@ function getBinaryPath(name) { return path.join( getAppPath(), "binaries", - `${name}-${goos}-${goarch}${ext}` + `${name}-${goos}-${goarch}${ext}`, ); } return path.join( process.resourcesPath, "binaries", - `${name}-${goos}-${goarch}${ext}` + `${name}-${goos}-${goarch}${ext}`, ); } @@ -90,6 +90,25 @@ function getUserConfigPath() { return path.join(configsDir, "config.toml"); } +/** + * Gets the path to the user app version file. + * @returns {string} The absolute path to the user's version.toml file. + * @example + * const configVersionPath = getVersionFilePath(); + * // Development: returns "electron-app/version.toml" + * // Production: returns "userData/configs/version.toml" + */ +function getVersionFilePath() { + if (!app.isPackaged) { + // Development: use local version.toml in project root + return path.join(getAppPath(), "version.toml"); + } + + // Production: user version in userData directory + const userConfigDir = app.getPath("userData"); + return path.join(userConfigDir, "version.toml"); +} + /** * Gets the path to the configuration template file. * @returns {string} The absolute path to the configuration template file. @@ -108,4 +127,10 @@ function getTemplatePath() { return path.join(process.resourcesPath, "config.toml"); } -export { getAppPath, getBinaryPath, getTemplatePath, getUserConfigPath }; +export { + getAppPath, + getBinaryPath, + getTemplatePath, + getUserConfigPath, + getVersionFilePath, +}; diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index ae5fc29b2..0e994eeac 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -81,6 +81,8 @@ function loadView(view) { // Construct path to view HTML file const viewPath = path.join(appPath, "renderer", view, "index.html"); + if (!mainWindow || mainWindow.isDestroyed()) return; + // Check if view file exists if (fs.existsSync(viewPath)) { // Load the view HTML file diff --git a/frontend/frontend-kit/ui/README.md b/frontend/frontend-kit/ui/README.md index 713357c0a..7b5a9e4f9 100644 --- a/frontend/frontend-kit/ui/README.md +++ b/frontend/frontend-kit/ui/README.md @@ -17,10 +17,6 @@ This package is the main UI and React shared component library for the Hyperloop We use a custom Rust-based tool, **icons-master**, to manage our Lucide icon exports. This tool helps keep our icons organized by category and ensures we don't have duplicate exports. -> **⚠️ Windows Support Only** -> -> The `icons-master` CLI currently only supports **Windows**. If you are on macOS or Linux, you must manually update the `.ts` files in `src/icons/`. - ### Usage Here are the scripts you can run: diff --git a/frontend/frontend-kit/ui/package.json b/frontend/frontend-kit/ui/package.json index 78d4fb424..bd2791edc 100644 --- a/frontend/frontend-kit/ui/package.json +++ b/frontend/frontend-kit/ui/package.json @@ -10,6 +10,7 @@ "icon:remove": "icons-master remove" }, "dependencies": { + "@base-ui/react": "^1.3.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -26,6 +27,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-resizable-panels": "^4.6.0", @@ -36,7 +38,7 @@ "zustand": "^5.0.11" }, "devDependencies": { - "@maximka76667/icons-master": "^1.0.1", + "@maximka76667/icons-master": "^1.0.3", "@tailwindcss/postcss": "^4.1.18", "@turbo/gen": "^2.8.3", "@types/node": "^25.2.0", diff --git a/frontend/frontend-kit/ui/src/components/shadcn/button.tsx b/frontend/frontend-kit/ui/src/components/shadcn/button.tsx index b25fb8e68..53fc38d33 100644 --- a/frontend/frontend-kit/ui/src/components/shadcn/button.tsx +++ b/frontend/frontend-kit/ui/src/components/shadcn/button.tsx @@ -1,30 +1,32 @@ -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import * as React from "react"; +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" -import { cn } from "@workspace/ui/lib/utils"; +import { cn } from "@workspace/ui/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", outline: - "border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: - "text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8", "icon-lg": "size-10", }, @@ -33,28 +35,30 @@ const buttonVariants = cva( variant: "default", size: "default", }, - }, -); + } +) function Button({ className, - variant, - size, + variant = "default", + size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; + asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot.Root : "button" return ( - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/frontend/frontend-kit/ui/src/components/shadcn/combobox.tsx b/frontend/frontend-kit/ui/src/components/shadcn/combobox.tsx new file mode 100644 index 000000000..36b00ad18 --- /dev/null +++ b/frontend/frontend-kit/ui/src/components/shadcn/combobox.tsx @@ -0,0 +1,310 @@ +"use client" + +import * as React from "react" +import { Combobox as ComboboxPrimitive } from "@base-ui/react" +import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react" + +import { cn } from "@workspace/ui/lib/utils" +import { Button } from "@workspace/ui/components/shadcn/button" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@workspace/ui/components/shadcn/input-group" + +const Combobox = ComboboxPrimitive.Root + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ) +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ) +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean + showClear?: boolean +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + + + + )} + {showClear && } + + {children} + + ) +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + "side" | "align" | "sideOffset" | "alignOffset" | "anchor" + >) { + return ( + + + + + + ) +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ) +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ) +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ) +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ) +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ) +} + +function ComboboxSeparator({ + className, + ...props +}: ComboboxPrimitive.Separator.Props) { + return ( + + ) +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef & + ComboboxPrimitive.Chips.Props) { + return ( + + ) +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean +}) { + return ( + + {children} + {showRemove && ( + } + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + + + )} + + ) +} + +function ComboboxChipsInput({ + className, + children, + ...props +}: ComboboxPrimitive.Input.Props) { + return ( + + ) +} + +function useComboboxAnchor() { + return React.useRef(null) +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +} diff --git a/frontend/frontend-kit/ui/src/components/shadcn/index.ts b/frontend/frontend-kit/ui/src/components/shadcn/index.ts index 9f5397a79..d2e4f43b5 100644 --- a/frontend/frontend-kit/ui/src/components/shadcn/index.ts +++ b/frontend/frontend-kit/ui/src/components/shadcn/index.ts @@ -1,4 +1,6 @@ export * from "./badge"; +export * from "./combobox"; +export * from "./input-group"; export * from "./button"; export * from "./card"; export * from "./checkbox"; diff --git a/frontend/frontend-kit/ui/src/components/shadcn/input-group.tsx b/frontend/frontend-kit/ui/src/components/shadcn/input-group.tsx new file mode 100644 index 000000000..3b8f6fece --- /dev/null +++ b/frontend/frontend-kit/ui/src/components/shadcn/input-group.tsx @@ -0,0 +1,170 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@workspace/ui/lib/utils" +import { Button } from "@workspace/ui/components/shadcn/button" +import { Input } from "@workspace/ui/components/shadcn/input" +import { Textarea } from "@workspace/ui/components/shadcn/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3", + "block-end": + "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +