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..36eff9182 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,15 +201,61 @@ const Board = memo(function () { return } - if (result.hasUsers) { - openModal('runtime-login', null) - } else { - openModal('runtime-create-user', null) + // Validate runtime version matches the selected board target + const versionValidation = validateRuntimeVersion(deviceBoard, result.runtimeVersion) + + // 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.message || 'Unknown version mismatch error', + buttons: ['OK'], + onResponse: () => { + // No action needed, just close the modal + }, + }) + return } + + 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') } - }, [runtimeIpAddress, connectionStatus, setRuntimeConnectionStatus, setRuntimeJwtToken, openModal]) + }, [runtimeIpAddress, connectionStatus, setRuntimeConnectionStatus, setRuntimeJwtToken, openModal, deviceBoard]) useEffect(() => { let statusInterval: NodeJS.Timeout | null = null 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) }} diff --git a/src/utils/device.ts b/src/utils/device.ts index 21262cd35..8388fcd73 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -26,3 +26,66 @@ 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 +} + +/** + * 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 status ('ok', 'missing', or 'mismatch') and an optional message + */ +export function validateRuntimeVersion( + boardTarget: string, + detectedVersion: string | undefined, +): RuntimeVersionValidationResult { + const expectedVersion = getExpectedRuntimeVersion(boardTarget) + + // If not a runtime target, no validation needed + if (!expectedVersion) { + return { status: 'ok' } + } + + // If no version detected from runtime, it might be an older runtime without the header + if (!detectedVersion) { + return { + 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.', + } + } + + // Normalize detected version for comparison (expectedVersion is already lowercase from getExpectedRuntimeVersion) + const normalizedDetected = detectedVersion.toLowerCase() + + if (normalizedDetected !== expectedVersion) { + return { + 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 { status: 'ok' } +}