From 60b385c04aec1842baa04c1aab9d959852b609e4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 22:54:58 +0000 Subject: [PATCH 1/4] Add runtime version detection to prevent version mismatch errors - Update handleRuntimeGetUsersInfo to capture X-OpenPLC-Runtime-Version header - Add runtimeVersion field to IPC bridge return type - Add validateRuntimeVersion utility function to check version compatibility - Show error modal when selected runtime version doesn't match connected runtime - Helps users identify when they're connecting to wrong runtime version Co-Authored-By: Thiago Alves --- src/main/modules/ipc/main.ts | 9 ++-- src/main/modules/ipc/renderer.ts | 2 +- .../editor/device/configuration/board.tsx | 20 ++++++- src/utils/device.ts | 52 +++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 9c3ab070c..c61e5f667 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -89,12 +89,15 @@ class MainProcessBridge implements MainIpcModule { data += chunk.toString() }) res.on('end', () => { + // Extract runtime version from response header + const runtimeVersion = res.headers['x-openplc-runtime-version'] as string | undefined + if (res.statusCode === 404) { - resolve({ hasUsers: false }) + resolve({ hasUsers: false, runtimeVersion }) } else if (res.statusCode === 200) { - resolve({ hasUsers: true }) + resolve({ hasUsers: true, runtimeVersion }) } else { - resolve({ hasUsers: false, error: data || `Unexpected status: ${res.statusCode}` }) + resolve({ hasUsers: false, error: data || `Unexpected status: ${res.statusCode}`, runtimeVersion }) } }) }, diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index b55b58744..dd526aa24 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -292,7 +292,7 @@ const rendererProcessBridge = { debuggerDisconnect: (): Promise<{ success: boolean }> => ipcRenderer.invoke('debugger:disconnect'), // ===================== RUNTIME API METHODS ===================== - runtimeGetUsersInfo: (ipAddress: string): Promise<{ hasUsers: boolean; error?: string }> => + runtimeGetUsersInfo: (ipAddress: string): Promise<{ hasUsers: boolean; runtimeVersion?: string; error?: string }> => ipcRenderer.invoke('runtime:get-users-info', ipAddress), runtimeCreateUser: ( ipAddress: string, diff --git a/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx index c4182e16a..cd17416a8 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -7,7 +7,7 @@ import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root import { DeviceEditorSlot } from '@root/renderer/components/_templates/[editors]' import { useOpenPLCStore } from '@root/renderer/store' import type { DeviceActions, RuntimeConnection, TimingStats } from '@root/renderer/store/slices/device/types' -import { cn, isArduinoTarget, isOpenPLCRuntimeTarget } from '@root/utils' +import { cn, isArduinoTarget, isOpenPLCRuntimeTarget, validateRuntimeVersion } from '@root/utils' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PinMappingTable } from './components/pin-mapping-table' @@ -201,6 +201,22 @@ const Board = memo(function () { return } + // Validate runtime version matches the selected board target + const versionValidation = validateRuntimeVersion(deviceBoard, result.runtimeVersion) + if (!versionValidation.isValid) { + setRuntimeConnectionStatus('error') + openModal('debugger-message', { + type: 'error', + title: 'Runtime Version Mismatch', + message: versionValidation.errorMessage || 'Unknown version mismatch error', + buttons: ['OK'], + onResponse: () => { + // No action needed, just close the modal + }, + }) + return + } + if (result.hasUsers) { openModal('runtime-login', null) } else { @@ -209,7 +225,7 @@ const Board = memo(function () { } catch (_error) { setRuntimeConnectionStatus('error') } - }, [runtimeIpAddress, connectionStatus, setRuntimeConnectionStatus, setRuntimeJwtToken, openModal]) + }, [runtimeIpAddress, connectionStatus, setRuntimeConnectionStatus, setRuntimeJwtToken, openModal, deviceBoard]) useEffect(() => { let statusInterval: NodeJS.Timeout | null = null diff --git a/src/utils/device.ts b/src/utils/device.ts index 21262cd35..8dc892365 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -26,3 +26,55 @@ export function isOpenPLCRuntimeTarget(boardInfo: AvailableBoardInfo | undefined } return boardInfo.compiler === 'openplc-compiler' } + +/** + * Extracts the expected runtime version from the board target name. + * This is used to validate that the connected runtime matches the selected target. + * + * @param boardTarget - The board target name (e.g., "OpenPLC Runtime v3", "OpenPLC Runtime v4") + * @returns The expected runtime version string (e.g., "v3", "v4") or undefined if not a runtime target + */ +export function getExpectedRuntimeVersion(boardTarget: string): string | undefined { + const match = boardTarget.match(/OpenPLC Runtime (v\d+)/i) + return match ? match[1].toLowerCase() : undefined +} + +/** + * Validates that the detected runtime version matches the expected version based on the selected board target. + * + * @param boardTarget - The selected board target name + * @param detectedVersion - The runtime version detected from the API response header + * @returns An object with isValid boolean and an optional error message + */ +export function validateRuntimeVersion( + boardTarget: string, + detectedVersion: string | undefined, +): { isValid: boolean; errorMessage?: string } { + const expectedVersion = getExpectedRuntimeVersion(boardTarget) + + // If not a runtime target, no validation needed + if (!expectedVersion) { + return { isValid: true } + } + + // If no version detected from runtime, it might be an older runtime without the header + if (!detectedVersion) { + return { + isValid: false, + errorMessage: `Could not detect runtime version. The runtime may need to be updated to support version detection.`, + } + } + + // Normalize versions for comparison (both should be lowercase like "v3", "v4") + const normalizedDetected = detectedVersion.toLowerCase() + const normalizedExpected = expectedVersion.toLowerCase() + + if (normalizedDetected !== normalizedExpected) { + return { + isValid: false, + errorMessage: `Runtime version mismatch: Selected "${boardTarget}" but connected to a ${detectedVersion.toUpperCase()} runtime. Please update your device configuration to match the connected runtime.`, + } + } + + return { isValid: true } +} From f344ee07e97efe6727ce7241efad2502877d66de Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 03:17:18 +0000 Subject: [PATCH 2/4] Address Copilot review comments - Use normalizedDetected.toUpperCase() instead of detectedVersion.toUpperCase() for safer code - Remove redundant .toLowerCase() on expectedVersion since getExpectedRuntimeVersion already returns lowercase Co-Authored-By: Thiago Alves --- src/utils/device.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/utils/device.ts b/src/utils/device.ts index 8dc892365..b8da8ec28 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -65,14 +65,13 @@ export function validateRuntimeVersion( } } - // Normalize versions for comparison (both should be lowercase like "v3", "v4") + // Normalize detected version for comparison (expectedVersion is already lowercase from getExpectedRuntimeVersion) const normalizedDetected = detectedVersion.toLowerCase() - const normalizedExpected = expectedVersion.toLowerCase() - if (normalizedDetected !== normalizedExpected) { + if (normalizedDetected !== expectedVersion) { return { isValid: false, - errorMessage: `Runtime version mismatch: Selected "${boardTarget}" but connected to a ${detectedVersion.toUpperCase()} runtime. Please update your device configuration to match the connected runtime.`, + errorMessage: `Runtime version mismatch: Selected "${boardTarget}" but connected to a ${normalizedDetected.toUpperCase()} runtime. Please update your device configuration to match the connected runtime.`, } } From 6f0f869650d2f9661a6cf641c79b932fb15eb0cf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:20:37 +0000 Subject: [PATCH 3/4] Change older runtime detection to warning dialog with Continue Anyway option - Modify validateRuntimeVersion to return status: 'ok' | 'missing' | 'mismatch' - Show warning dialog for older runtimes without version header - Add 'Continue Anyway' and 'Cancel' buttons to allow user to proceed - Update message to suggest updating runtime to latest version Co-Authored-By: Thiago Alves --- .../editor/device/configuration/board.tsx | 42 ++++++++++++++++--- src/utils/device.ts | 28 +++++++++---- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx index cd17416a8..36eff9182 100644 --- a/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/renderer/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -203,12 +203,23 @@ const Board = memo(function () { // Validate runtime version matches the selected board target const versionValidation = validateRuntimeVersion(deviceBoard, result.runtimeVersion) - if (!versionValidation.isValid) { + + // Helper to proceed with connection after validation + const proceedWithConnection = () => { + if (result.hasUsers) { + openModal('runtime-login', null) + } else { + openModal('runtime-create-user', null) + } + } + + if (versionValidation.status === 'mismatch') { + // Hard error for version mismatch - cannot proceed setRuntimeConnectionStatus('error') openModal('debugger-message', { type: 'error', title: 'Runtime Version Mismatch', - message: versionValidation.errorMessage || 'Unknown version mismatch error', + message: versionValidation.message || 'Unknown version mismatch error', buttons: ['OK'], onResponse: () => { // No action needed, just close the modal @@ -217,11 +228,30 @@ const Board = memo(function () { return } - if (result.hasUsers) { - openModal('runtime-login', null) - } else { - openModal('runtime-create-user', null) + if (versionValidation.status === 'missing') { + // Warning for older runtimes - allow user to continue anyway + // Note: buttons ordered as ['Continue Anyway', 'Cancel'] so Cancel (index 1) is the default + // when closing the modal (DebuggerMessageModal calls onResponse with last button index on close) + openModal('debugger-message', { + type: 'warning', + title: 'Older Runtime Detected', + message: versionValidation.message || 'Could not detect runtime version.', + buttons: ['Continue Anyway', 'Cancel'], + onResponse: (buttonIndex: number) => { + if (buttonIndex === 0) { + // User clicked "Continue Anyway" - proceed with connection + proceedWithConnection() + } else { + // User clicked "Cancel" or closed the modal - stay disconnected + setRuntimeConnectionStatus('disconnected') + } + }, + }) + return } + + // Version is OK - proceed normally + proceedWithConnection() } catch (_error) { setRuntimeConnectionStatus('error') } diff --git a/src/utils/device.ts b/src/utils/device.ts index b8da8ec28..8388fcd73 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -39,29 +39,41 @@ export function getExpectedRuntimeVersion(boardTarget: string): string | undefin return match ? match[1].toLowerCase() : undefined } +/** + * Result of runtime version validation. + * - 'ok': Version matches or not a runtime target + * - 'missing': Runtime didn't report version (older runtime without header) + * - 'mismatch': Version reported but doesn't match selected target + */ +export type RuntimeVersionValidationResult = { + status: 'ok' | 'missing' | 'mismatch' + message?: string +} + /** * Validates that the detected runtime version matches the expected version based on the selected board target. * * @param boardTarget - The selected board target name * @param detectedVersion - The runtime version detected from the API response header - * @returns An object with isValid boolean and an optional error message + * @returns An object with status ('ok', 'missing', or 'mismatch') and an optional message */ export function validateRuntimeVersion( boardTarget: string, detectedVersion: string | undefined, -): { isValid: boolean; errorMessage?: string } { +): RuntimeVersionValidationResult { const expectedVersion = getExpectedRuntimeVersion(boardTarget) // If not a runtime target, no validation needed if (!expectedVersion) { - return { isValid: true } + return { status: 'ok' } } // If no version detected from runtime, it might be an older runtime without the header if (!detectedVersion) { return { - isValid: false, - errorMessage: `Could not detect runtime version. The runtime may need to be updated to support version detection.`, + status: 'missing', + message: + 'The runtime you are connecting to is older than the version of the editor. You may experience connection issues. Please update your runtime to the latest version.', } } @@ -70,10 +82,10 @@ export function validateRuntimeVersion( if (normalizedDetected !== expectedVersion) { return { - isValid: false, - errorMessage: `Runtime version mismatch: Selected "${boardTarget}" but connected to a ${normalizedDetected.toUpperCase()} runtime. Please update your device configuration to match the connected runtime.`, + status: 'mismatch', + message: `Runtime version mismatch: Selected "${boardTarget}" but connected to a ${normalizedDetected.toUpperCase()} runtime. Please update your device configuration to match the connected runtime.`, } } - return { isValid: true } + return { status: 'ok' } } From 074c49119fe5c87d6e86d476c217b26da1d1e4a2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:50:17 +0000 Subject: [PATCH 4/4] Fix race condition: close modal before calling onResponse callback When the debugger-message modal's onResponse callback opens another modal, the subsequent closeModal() call was closing ALL modals including the newly opened one. Fix by capturing the callback, closing the modal first, then calling the callback. This fixes the issue where clicking 'Continue Anyway' on the older runtime warning dialog would not show the login/create-user dialog. Co-Authored-By: Thiago Alves --- .../modals/debugger-message-modal.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/_organisms/modals/debugger-message-modal.tsx b/src/renderer/components/_organisms/modals/debugger-message-modal.tsx index fb1d2f06a..2c12923d8 100644 --- a/src/renderer/components/_organisms/modals/debugger-message-modal.tsx +++ b/src/renderer/components/_organisms/modals/debugger-message-modal.tsx @@ -14,10 +14,14 @@ const DebuggerMessageModal = () => { } | null const handleButtonClick = (index: number) => { - if (modalData?.onResponse) { - modalData.onResponse(index) - } + // Capture callback before closing (closeModal clears data) + const onResponse = modalData?.onResponse + // Close modal first, then call callback to avoid race condition + // when callback opens another modal modalActions.closeModal() + if (onResponse) { + onResponse(index) + } } if (!modalData) return null @@ -41,10 +45,15 @@ const DebuggerMessageModal = () => { open={isOpen} onOpenChange={(open) => { if (!open) { - if (modalData?.onResponse) { - modalData.onResponse(modalData.buttons.length - 1) - } + // Capture callback and button count before closing (closeModal clears data) + const onResponse = modalData?.onResponse + const lastButtonIndex = modalData?.buttons?.length ? modalData.buttons.length - 1 : 0 + // Close modal first, then call callback to avoid race condition + // when callback opens another modal modalActions.closeModal() + if (onResponse) { + onResponse(lastButtonIndex) + } } modalActions.onOpenChange('debugger-message', open) }}