From 162c815c16609567331b3a89e279e6a11115631d Mon Sep 17 00:00:00 2001 From: Vasyl Date: Mon, 2 Mar 2026 20:40:26 +0100 Subject: [PATCH 01/10] chore(backend): move adj pkg --- backend/{internal => pkg}/adj/adj.go | 0 backend/{internal => pkg}/adj/boards.go | 0 backend/{internal => pkg}/adj/git.go | 0 backend/{internal => pkg}/adj/models.go | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename backend/{internal => pkg}/adj/adj.go (100%) rename backend/{internal => pkg}/adj/boards.go (100%) rename backend/{internal => pkg}/adj/git.go (100%) rename backend/{internal => pkg}/adj/models.go (100%) diff --git a/backend/internal/adj/adj.go b/backend/pkg/adj/adj.go similarity index 100% rename from backend/internal/adj/adj.go rename to backend/pkg/adj/adj.go diff --git a/backend/internal/adj/boards.go b/backend/pkg/adj/boards.go similarity index 100% rename from backend/internal/adj/boards.go rename to backend/pkg/adj/boards.go diff --git a/backend/internal/adj/git.go b/backend/pkg/adj/git.go similarity index 100% rename from backend/internal/adj/git.go rename to backend/pkg/adj/git.go diff --git a/backend/internal/adj/models.go b/backend/pkg/adj/models.go similarity index 100% rename from backend/internal/adj/models.go rename to backend/pkg/adj/models.go From a459595ec600facd655a519560ccf7402e884d0b Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:00:46 +0100 Subject: [PATCH 02/10] fix: add resolve to promise --- electron-app/src/processes/backend.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 3c2c9720d..8e49079f5 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -173,8 +173,9 @@ async function stopBackend() { fallbackTimer.unref(); } else { logger.backend.warning("Backend process not found, skipping stop..."); - resolve(); } + + resolve(); }); } From 59b8f0998dc9a3ca1404b4f93b38f9709ac47515 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:19:44 +0100 Subject: [PATCH 03/10] go back --- electron-app/src/processes/backend.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 8e49079f5..3c2c9720d 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -173,9 +173,8 @@ async function stopBackend() { fallbackTimer.unref(); } else { logger.backend.warning("Backend process not found, skipping stop..."); + resolve(); } - - resolve(); }); } From b832fc473d2db63b7e011f858060f933c26d0f48 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:05:28 +0100 Subject: [PATCH 04/10] feat: remove pacman and rpm distributives --- .github/workflows/release.yaml | 3 --- electron-app/package.json | 4 +--- 2 files changed, 1 insertion(+), 6 deletions(-) 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/electron-app/package.json b/electron-app/package.json index c5957648d..a64c47b0f 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -107,9 +107,7 @@ "linux": { "target": [ "AppImage", - "deb", - "rpm", - "pacman" + "deb" ], "icon": "icons/512x512.png", "category": "Utility", From 7100c993370194ca535a3d2030c612b8da43fa9e Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 7 Mar 2026 12:30:54 +0100 Subject: [PATCH 05/10] feat: add loading when saving settings and fix backend panic --- README.md | 25 ++++++------ electron-app/build.mjs | 5 ++- electron-app/main.js | 2 - electron-app/package.json | 2 +- electron-app/src/processes/backend.js | 12 +++--- .../components/settings/SettingsDialog.tsx | 40 ++++++++++++------- .../src/components/settings/SettingsForm.tsx | 2 +- package.json | 7 ++-- 8 files changed, 56 insertions(+), 39 deletions(-) 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/electron-app/build.mjs b/electron-app/build.mjs index b4dff1753..49f3ee98a 100644 --- a/electron-app/build.mjs +++ b/electron-app/build.mjs @@ -254,7 +254,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..771e0ab1d 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 diff --git a/electron-app/package.json b/electron-app/package.json index a64c47b0f..9b95bd2bf 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", diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index 3c2c9720d..acd99ab78 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -108,11 +108,6 @@ async function startBackend(logWindow = null) { 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}`); @@ -131,7 +126,14 @@ async function startBackend(logWindow = null) { // Clear error message after showing lastBackendError = null; } + + backendProcess = null; }); + + // If the backend didn't fail in this period of time, resolve the promise + setTimeout(() => { + resolve(backendProcess); + }, 2000); }); } diff --git a/frontend/testing-view/src/components/settings/SettingsDialog.tsx b/frontend/testing-view/src/components/settings/SettingsDialog.tsx index f62b29d31..9eb6b7e36 100644 --- a/frontend/testing-view/src/components/settings/SettingsDialog.tsx +++ b/frontend/testing-view/src/components/settings/SettingsDialog.tsx @@ -7,7 +7,8 @@ import { DialogHeader, DialogTitle, } from "@workspace/ui/components/shadcn/dialog"; -import { useEffect, useState } from "react"; +import { Loader2 } from "@workspace/ui/icons"; +import { useEffect, useState, useTransition } from "react"; import { AlertTriangle, CheckCircle2, @@ -24,6 +25,7 @@ export const SettingsDialog = () => { const setRestarting = useStore((s) => s.setRestarting); const [localConfig, setLocalConfig] = useState(null); const [isSynced, setIsSynced] = useState(false); + const [isSaving, startSaving] = useTransition(); const loadConfig = async () => { if (window.electronAPI) { @@ -54,24 +56,26 @@ export const SettingsDialog = () => { }, [isSettingsOpen]); const handleSave = async () => { - if (window.electronAPI) { - await window.electronAPI.saveConfig(localConfig); - } else { - console.log("Electron API not available. Using default config."); - } + startSaving(async () => { + if (window.electronAPI) { + await window.electronAPI.saveConfig(localConfig); + } else { + console.log("Electron API not available. Using default config."); + } - setRestarting(true); + setRestarting(true); - setTimeout(() => { - socketService.connect(); - setSettingsOpen(false); - setRestarting(false); - }, config.SETTINGS_RESPONSE_TIMEOUT); + setTimeout(() => { + socketService.connect(); + setSettingsOpen(false); + setRestarting(false); + }, config.SETTINGS_RESPONSE_TIMEOUT); + }); }; return ( - +
System Configuration @@ -101,7 +105,15 @@ export const SettingsDialog = () => { - +
diff --git a/frontend/testing-view/src/components/settings/SettingsForm.tsx b/frontend/testing-view/src/components/settings/SettingsForm.tsx index c69512c55..c0d3df189 100644 --- a/frontend/testing-view/src/components/settings/SettingsForm.tsx +++ b/frontend/testing-view/src/components/settings/SettingsForm.tsx @@ -101,7 +101,7 @@ export const SettingsForm = ({ config, onChange }: SettingsFormProps) => {
{schema.map((section) => (
-

+

{section.title}

diff --git a/package.json b/package.json index 942aad474..80383d0b3 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "dev": "turbo dev", "dev:main": "turbo dev:main", "build": "turbo build", - "build:win": "pnpm --filter electron-app build:win", - "build:linux": "pnpm --filter electron-app build:linux", - "build:mac": "pnpm --filter electron-app build:mac", + "build:win": "pnpm --filter hyperloop-control-station build:win", + "build:linux": "pnpm --filter hyperloop-control-station build:linux", + "build:mac": "pnpm --filter hyperloop-control-station build:mac", + "start": "pnpm --filter hyperloop-control-station start", "lint": "turbo lint", "preview": "turbo preview", "test": "turbo test", From 2599adeb34dd5b5e76dbcb9333bd2c447ea16233 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:26:02 +0100 Subject: [PATCH 06/10] fix: apply some fixes --- electron-app/main.js | 10 ++--- electron-app/preload.js | 8 +++- .../config/__tests__/updateTomlValue.test.js | 41 +++++++++++++++++++ electron-app/src/config/configManager.js | 14 ++++++- electron-app/src/processes/backend.js | 12 +++--- electron-app/src/windows/mainWindow.js | 2 + .../charts/components/ChartLegend.tsx | 15 +++---- .../charts/components/ChartSurface.tsx | 10 ++--- .../charts/components/TelemetryChart.tsx | 23 ++++++----- .../src/features/charts/store/chartsSlice.ts | 2 + .../tabs/commands/CommandItem.tsx | 21 ++++++++-- .../tabs/commands/CommandParameters.tsx | 4 +- .../workspace/store/workspacesSlice.ts | 6 +-- frontend/testing-view/src/hooks/useAppMode.ts | 16 ++------ .../testing-view/src/hooks/useBoardData.ts | 2 - .../testing-view/src/hooks/useConnections.ts | 11 ++--- 16 files changed, 129 insertions(+), 68 deletions(-) diff --git a/electron-app/main.js b/electron-app/main.js index 771e0ab1d..07167f22c 100644 --- a/electron-app/main.js +++ b/electron-app/main.js @@ -116,11 +116,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/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__/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/configManager.js b/electron-app/src/config/configManager.js index adfeed3b9..9b237c673 100644 --- a/electron-app/src/config/configManager.js +++ b/electron-app/src/config/configManager.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) { @@ -104,7 +116,7 @@ function updateTomlValue(tomlContent, section, key, newValue) { } // Join lines back into string - return result.join("\n"); + return result.join(lineEnding); } /** diff --git a/electron-app/src/processes/backend.js b/electron-app/src/processes/backend.js index acd99ab78..d0c77ee99 100644 --- a/electron-app/src/processes/backend.js +++ b/electron-app/src/processes/backend.js @@ -88,8 +88,7 @@ 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; + if (errorMsg) lastBackendError = errorMsg; // Send error message to log window if (currentLogWindow && !currentLogWindow.isDestroyed()) { @@ -111,9 +110,9 @@ async function startBackend(logWindow = null) { // 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) { @@ -123,15 +122,16 @@ 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 - setTimeout(() => { + const startupTimer = setTimeout(() => { resolve(backendProcess); }, 2000); }); 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/testing-view/src/features/charts/components/ChartLegend.tsx b/frontend/testing-view/src/features/charts/components/ChartLegend.tsx index 576c0d74a..eab336026 100644 --- a/frontend/testing-view/src/features/charts/components/ChartLegend.tsx +++ b/frontend/testing-view/src/features/charts/components/ChartLegend.tsx @@ -6,15 +6,15 @@ import { ChartSettings } from "./ChartSettings"; interface ChartLegendProps { chartId: string; series: WorkspaceChartSeries[]; - disabledIndices: Set; - onToggle: (index: number) => void; - onRemove: (variable: string, index: number) => void; + disabledVariables: Set; + onToggle: (seriesKey: string) => void; + onRemove: (variable: string) => void; } export const ChartLegend = ({ chartId, series, - disabledIndices, + disabledVariables, onToggle, onRemove, }: ChartLegendProps) => ( @@ -25,9 +25,9 @@ export const ChartLegend = ({ className="border-border flex items-center overflow-hidden rounded-md border shadow-sm transition-transform active:scale-95" >
); diff --git a/frontend/testing-view/src/features/charts/store/chartsSlice.ts b/frontend/testing-view/src/features/charts/store/chartsSlice.ts index b74827008..ae5ca8427 100644 --- a/frontend/testing-view/src/features/charts/store/chartsSlice.ts +++ b/frontend/testing-view/src/features/charts/store/chartsSlice.ts @@ -33,6 +33,8 @@ export interface ChartsSlice { variable: string, ) => void; + clearCharts: () => void; + /** Sets new buffer size or history limit for a chart. */ setChartHistoryLimit: ( workspaceId: string, diff --git a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx index 6de562468..7b0374313 100644 --- a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx +++ b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandItem.tsx @@ -33,8 +33,15 @@ export const CommandItem = ({ item: commandCatalogItem }: CommandItemProps) => { const hasParameters = Object.keys(commandCatalogItem.fields).length > 0; const paramCount = Object.keys(commandCatalogItem.fields).length; + const hasInvalidNumeric = Object.entries(commandCatalogItem.fields).some( + ([key, field]) => + field.kind === "numeric" && + !Number.isFinite(parseFloat(parameterValues[key])), + ); + const handleRun = (e: React.MouseEvent) => { e.stopPropagation(); + if (hasInvalidNumeric) return; const payload = { id: commandCatalogItem.id, @@ -80,8 +87,14 @@ export const CommandItem = ({ item: commandCatalogItem }: CommandItemProps) => {
@@ -128,7 +141,8 @@ export const CommandItem = ({ item: commandCatalogItem }: CommandItemProps) => { {/* Send button at bottom */} diff --git a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandParameters.tsx b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandParameters.tsx index 52dd8454b..ec3f19cfb 100644 --- a/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandParameters.tsx +++ b/frontend/testing-view/src/features/workspace/components/rightSidebar/tabs/commands/CommandParameters.tsx @@ -45,7 +45,7 @@ export const CommandParameters = ({ id={field.id} type="number" placeholder={field.type} - value={values[field.id] || ""} + value={values[field.id] ?? ""} onChange={(e) => onChange(field.id, e.target.value)} className="h-8 text-xs" /> @@ -74,7 +74,7 @@ export const CommandParameters = ({ {field.name} activeWorkspace?.id && From 65739de053ac231f40fae40ef4ff172d363af214 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:37:43 +0100 Subject: [PATCH 08/10] style: change product name --- electron-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron-app/package.json b/electron-app/package.json index 9b95bd2bf..2d936c068 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -57,7 +57,7 @@ "owner": "Hyperloop-UPV", "repo": "software" }, - "productName": "Hyperloop-Control-Station", + "productName": "Hyperloop-Ctrl", "directories": { "output": "dist" }, From 1262777cd20146fd1def8b87a91f1cfa0aa0ec2a Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:32:44 +0100 Subject: [PATCH 09/10] feat: implement versioning --- electron-app/.gitignore | 3 +- electron-app/src/config/configInstance.js | 17 ++++++- electron-app/src/config/configManager.js | 62 +++++++++++++++++------ electron-app/src/utils/paths.js | 31 ++++++++++-- 4 files changed, 92 insertions(+), 21 deletions(-) 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/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 9b237c673..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"; /** @@ -69,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 @@ -89,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) { @@ -111,7 +111,7 @@ 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`, ); } @@ -165,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(); @@ -192,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})`, + ); } } @@ -360,4 +392,4 @@ class ConfigManager { } } -export { ConfigManager, updateTomlValue, updateTomlFromObject }; +export { ConfigManager, updateTomlFromObject, updateTomlValue }; diff --git a/electron-app/src/utils/paths.js b/electron-app/src/utils/paths.js index 1d44ceb39..a4b48b79c 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 config.toml in project root + return path.join(getAppPath(), "version.toml"); + } + + // Production: user config 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, +}; From 8d4528ad7e8615ece19f53f4699ea25448c97880 Mon Sep 17 00:00:00 2001 From: Maxim <74974283+maximka76667@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:56:16 +0100 Subject: [PATCH 10/10] fix: make tests pass --- .../ConfigManager.initialization.test.js | 24 +++-- .../ConfigManager.read-write.test.js | 67 ++++++++++++-- .../__tests__/ConfigManager.utilities.test.js | 88 ++++++++++++++++--- 3 files changed, 151 insertions(+), 28 deletions(-) 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);