Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/main/modules/ipc/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
})
},
Expand Down
2 changes: 1 addition & 1 deletion src/main/modules/ipc/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}}
Expand Down
63 changes: 63 additions & 0 deletions src/utils/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}
Loading