diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d38bbada3..fedc90c66 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,9 +8,9 @@ "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, - "forwardPorts": [1212, 3000], + "forwardPorts": [1313, 3000], "portsAttributes": { - "1212": { "label": "Webpack Dev Server (Renderer)" }, + "1313": { "label": "Webpack Dev Server (Renderer)" }, "3000": { "label": "Electron Debug Port" } }, "mounts": [ diff --git a/.github/workflows/migration-ci.yml b/.github/workflows/migration-ci.yml index 5463468bd..a466a2dea 100644 --- a/.github/workflows/migration-ci.yml +++ b/.github/workflows/migration-ci.yml @@ -22,10 +22,10 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Validate architecture layers - run: npx tsx src2/__architecture__/validate.ts + run: npx tsx src/__architecture__/validate.ts unit-tests: name: Unit Tests + Coverage (src2) @@ -41,10 +41,10 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - - name: Run src2 tests with 100% coverage threshold - run: npx jest --config jest.config.src2.json --collectCoverage --ci + - name: Run tests with coverage + run: npx jest --config jest.config.json --collectCoverage --ci - name: Upload coverage report uses: actions/upload-artifact@v4 diff --git a/configs/webpack/webpack.config.main.dev.ts b/configs/webpack/webpack.config.main.dev.ts index 00d6731c5..07d4a8188 100644 --- a/configs/webpack/webpack.config.main.dev.ts +++ b/configs/webpack/webpack.config.main.dev.ts @@ -1,5 +1,7 @@ /** - * Webpack config for development electron main process + * Webpack config for development electron main process (new src) + * + * Builds from src/main/. All @root imports resolve to src/. */ import { join } from 'path' @@ -7,16 +9,17 @@ import webpack from 'webpack' import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer' import { merge } from 'webpack-merge' +import { dependencies as externals } from '../../release/app/package.json' import checkNodeEnv from '../../scripts/check-node-env' -import baseConfig from './webpack.config.base' import webpackPaths from './webpack.paths' -// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's -// at the dev webpack config is not accidentally run in a production environment if (process.env.NODE_ENV === 'production') { checkNodeEnv('development') } +const srcPath = join(webpackPaths.rootPath, 'src') +const srcMainPath = join(srcPath, 'main') + const configuration: webpack.Configuration = { devtool: 'inline-source-map', @@ -24,9 +27,31 @@ const configuration: webpack.Configuration = { target: 'electron-main', + externals: [...Object.keys(externals || {}), 'terser-webpack-plugin'], + + stats: 'errors-only', + + module: { + rules: [ + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: 'ts-loader', + options: { + transpileOnly: true, + compilerOptions: { + module: 'esnext', + }, + }, + }, + }, + ], + }, + entry: { - main: join(webpackPaths.srcMainPath, 'main.ts'), - preload: join(webpackPaths.srcMainPath, 'modules/preload/preload.ts'), + main: join(srcMainPath, 'main.ts'), + preload: join(srcMainPath, 'modules/preload/preload.ts'), }, output: { @@ -37,12 +62,19 @@ const configuration: webpack.Configuration = { }, }, - resolve: { - extensions: ['.ts', '.js'], + extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], + modules: [srcPath, 'node_modules'], + alias: { + '@root/utils': join(srcPath, 'frontend', 'utils'), + '@root': srcPath, + }, }, plugins: [ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + }), // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore new BundleAnalyzerPlugin({ @@ -54,15 +86,10 @@ const configuration: webpack.Configuration = { }), ], - /** - * Disables webpack processing of __dirname and __filename. - * If you run the bundle in node.js it falls back to these values of node.js. - * https://github.com/webpack/webpack/issues/2010 - */ node: { __dirname: false, __filename: false, }, } -export default merge(baseConfig, configuration) +export default configuration diff --git a/configs/webpack/webpack.config.main.old.dev.ts b/configs/webpack/webpack.config.main.old.dev.ts new file mode 100644 index 000000000..00d6731c5 --- /dev/null +++ b/configs/webpack/webpack.config.main.old.dev.ts @@ -0,0 +1,68 @@ +/** + * Webpack config for development electron main process + */ + +import { join } from 'path' +import webpack from 'webpack' +import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer' +import { merge } from 'webpack-merge' + +import checkNodeEnv from '../../scripts/check-node-env' +import baseConfig from './webpack.config.base' +import webpackPaths from './webpack.paths' + +// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's +// at the dev webpack config is not accidentally run in a production environment +if (process.env.NODE_ENV === 'production') { + checkNodeEnv('development') +} + +const configuration: webpack.Configuration = { + devtool: 'inline-source-map', + + mode: 'development', + + target: 'electron-main', + + entry: { + main: join(webpackPaths.srcMainPath, 'main.ts'), + preload: join(webpackPaths.srcMainPath, 'modules/preload/preload.ts'), + }, + + output: { + path: webpackPaths.dllPath, + filename: '[name].bundle.dev.js', + library: { + type: 'umd', + }, + }, + + + resolve: { + extensions: ['.ts', '.js'], + }, + + plugins: [ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + new BundleAnalyzerPlugin({ + analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled', + analyzerPort: 8888, + }), + new webpack.DefinePlugin({ + 'process.type': '"browser"', + }), + ], + + /** + * Disables webpack processing of __dirname and __filename. + * If you run the bundle in node.js it falls back to these values of node.js. + * https://github.com/webpack/webpack/issues/2010 + */ + node: { + __dirname: false, + __filename: false, + }, +} + +export default merge(baseConfig, configuration) diff --git a/configs/webpack/webpack.config.renderer.dev.ts b/configs/webpack/webpack.config.renderer.dev.ts index 4e26b436e..0825e6132 100644 --- a/configs/webpack/webpack.config.renderer.dev.ts +++ b/configs/webpack/webpack.config.renderer.dev.ts @@ -1,187 +1,77 @@ -import 'webpack-dev-server' +/** + * Webpack dev config for the new src/ renderer. + * + * Extends the standard renderer dev config but swaps the entry point and + * HTML template to use src/ instead of src_old/renderer/. + * The main process, preload, DLL, and all build infrastructure remain identical. + * + * Usage: npm run start:dev + */ -import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin' -import autoprefixer from 'autoprefixer' -import chalk from 'chalk' -import { execSync, spawn } from 'child_process' import EslintPlugin from 'eslint-webpack-plugin' -import fs from 'fs' import HtmlWebpackPlugin from 'html-webpack-plugin' -import MonacoEditorWebpackPlugin from 'monaco-editor-webpack-plugin' -import { join, resolve } from 'path' -import tailwindcss from 'tailwindcss' +import { join } from 'path' import webpack from 'webpack' -import { merge } from 'webpack-merge' +import { mergeWithCustomize, customizeArray } from 'webpack-merge' -import checkNodeEnv from '../../scripts/check-node-env' -import { getAppInfoDefines } from './webpack.app-info' -import baseConfig from './webpack.config.base' +import rendererDevConfig from './webpack.config.renderer.old.dev' import webpackPaths from './webpack.paths' -// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's -// at the dev webpack config is not accidentally run in a production environment -if (process.env.NODE_ENV === 'production') { - checkNodeEnv('development') -} - -const port = process.env.PORT || 1212 -const manifest = resolve(webpackPaths.dllPath, 'renderer.json') -const skipDLLs = - module.parent?.filename.includes('webpack.config.renderer.dev.dll') || - module.parent?.filename.includes('webpack.config.eslint') - -/** - * Warn if the DLL is not built - */ -if (!skipDLLs && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) { - console.log( - chalk.black.bgYellow.bold( - 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', - ), - ) - execSync('npm run postinstall') -} - -interface ICustomConfiguration extends webpack.Configuration { - devServer?: object +const port = process.env.PORT || 1313 +const srcPath = join(webpackPaths.rootPath, 'src') + +// Remove the base HtmlWebpackPlugin and EslintPlugin so we can replace/skip them +const basePlugins = (rendererDevConfig.plugins ?? []).filter( + (p) => !(p instanceof HtmlWebpackPlugin) && !(p instanceof EslintPlugin), +) + +// Remove the duplicate bare ts-loader rule (/\.ts?$/) from the renderer config. +// The base config already handles .ts files via /\.[jt]sx?$/ with transpileOnly + module:'esnext'. +// The duplicate causes double-compilation: the bare ts-loader uses tsconfig's module:"commonjs", +// injecting `exports.xxx` references that are undefined in webpack's module scope. +const baseRules = (rendererDevConfig.module?.rules ?? []).filter((r) => { + if (r && typeof r === 'object' && 'test' in r && r.test instanceof RegExp) { + return r.test.toString() !== '/\\.ts?$/' + } + return true +}) + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const { devServer: _devServer, ...rendererWithoutDevServer } = rendererDevConfig + +const baseConfig = { + ...rendererWithoutDevServer, + module: { ...rendererDevConfig.module, rules: baseRules }, + plugins: basePlugins, } -const configuration: ICustomConfiguration = { - devtool: 'inline-source-map', - - mode: 'development', - - target: ['web', 'electron-renderer'], - +const srcOverrides: webpack.Configuration = { entry: [ `webpack-dev-server/client?http://localhost:${port}/dist`, 'webpack/hot/only-dev-server', - join(webpackPaths.srcRendererPath, 'index.tsx'), + join(srcPath, 'main.tsx'), ], - output: { - path: webpackPaths.distRendererPath, - publicPath: '/', - filename: 'renderer.dev.js', - library: { - type: 'umd', - }, - }, - - module: { - rules: [ - { - test: /\.s?(c|a)ss$/, - use: [ - 'style-loader', - { - loader: 'css-loader', - options: { - modules: true, - sourceMap: true, - importLoaders: 1, - }, - }, - 'sass-loader', - ], - include: /\.module\.s?(c|a)ss$/, - }, - { - test: /\.s?css$/, - use: [ - 'style-loader', - 'css-loader', - 'sass-loader', - { - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: [tailwindcss, autoprefixer], - }, - }, - }, - ], - exclude: /\.module\.s?(c|a)ss$/, - }, - // Fonts - { - test: /\.(woff|woff2|eot|ttf|otf)$/i, - type: 'asset/resource', - }, - // Images - { - test: /\.(png|jpg|jpeg|gif)$/i, - type: 'asset/resource', - }, - // SVG - { - test: /\.svg$/, - use: [ - { - loader: '@svgr/webpack', - options: { - prettier: false, - svgo: false, - svgoConfig: { - plugins: [{ removeViewBox: false }], - }, - titleProp: true, - ref: true, - }, - }, - 'file-loader', - ], - }, - - ], + devServer: { + port, + compress: true, + hot: true, + headers: { 'Access-Control-Allow-Origin': '*' }, + static: { publicPath: '/' }, + historyApiFallback: { verbose: true }, + // No setupMiddlewares — start:electron handles main process separately. }, resolve: { - extensions: ['.ts', '.js'], + alias: { + '@src': join(webpackPaths.rootPath, 'src'), + }, }, - plugins: [ - ...(skipDLLs - ? [] - : [ - new webpack.DllReferencePlugin({ - context: webpackPaths.dllPath, - manifest: require(manifest), - sourceType: 'var', - }), - ]), - - new webpack.NoEmitOnErrorsPlugin(), - - /** - * Create global constants which can be configured at compile time. - * - * Useful for allowing different behaviour between development builds and - * release builds - * - * NODE_ENV should be production so that modules do not perform certain - * development checks - * - * By default, use 'development' as NODE_ENV. This can be overriden with - * 'staging', for example, by changing the ENV variables in the npm scripts - */ - new webpack.EnvironmentPlugin({ - NODE_ENV: 'development', - }), - - new webpack.DefinePlugin({ - ...getAppInfoDefines(), - }), - - new webpack.LoaderOptionsPlugin({ - options: {}, - debug: true, - }), - - new ReactRefreshWebpackPlugin(), + plugins: [ new HtmlWebpackPlugin({ - filename: join('index.html'), - template: join(webpackPaths.srcRendererPath, 'index.ejs'), + filename: 'index.html', + template: join(srcPath, 'index.ejs'), minify: { collapseWhitespace: true, removeAttributeQuotes: true, @@ -192,60 +82,11 @@ const configuration: ICustomConfiguration = { isDevelopment: process.env.NODE_ENV !== 'production', nodeModules: webpackPaths.appNodeModulesPath, }), - - new MonacoEditorWebpackPlugin({ - languages: ['python'], - }), - - new EslintPlugin({ - configType: 'flat', - extensions: ['ts', 'tsx'], - eslintPath: 'eslint/use-at-your-own-risk', - }), ], - - node: { - __dirname: false, - __filename: false, - }, - - devServer: { - port, - compress: true, - hot: true, - headers: { 'Access-Control-Allow-Origin': '*' }, - static: { - publicPath: '/', - }, - historyApiFallback: { - verbose: true, - }, - setupMiddlewares(middlewares: never) { - console.log('Starting preload.js builder...') - const preloadProcess = spawn('npm', ['run', 'start:preload'], { - shell: true, - stdio: 'inherit', - }) - .on('close', (code: number) => process.exit(code)) - .on('error', (spawnError) => console.error(spawnError)) - - console.log('Starting Main Process...') - let args = ['run', 'start:main'] - if (process.env.MAIN_ARGS) { - args = args.concat(['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat()) - } - spawn('npm', args, { - shell: true, - stdio: 'inherit', - }) - .on('close', (code: number) => { - preloadProcess.kill() - process.exit(code) - }) - .on('error', (spawnError) => console.error(spawnError)) - return middlewares - }, - }, } -export default merge(baseConfig, configuration) +export default mergeWithCustomize({ + customizeArray: customizeArray({ + entry: 'replace', + }), +})(baseConfig, srcOverrides) diff --git a/configs/webpack/webpack.config.renderer.old.dev.ts b/configs/webpack/webpack.config.renderer.old.dev.ts new file mode 100644 index 000000000..28649a5d3 --- /dev/null +++ b/configs/webpack/webpack.config.renderer.old.dev.ts @@ -0,0 +1,256 @@ +import 'webpack-dev-server' + +import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin' +import autoprefixer from 'autoprefixer' +import chalk from 'chalk' +import { execSync, spawn } from 'child_process' +import EslintPlugin from 'eslint-webpack-plugin' +import fs from 'fs' +import HtmlWebpackPlugin from 'html-webpack-plugin' +import MonacoEditorWebpackPlugin from 'monaco-editor-webpack-plugin' +import { join, resolve } from 'path' +import tailwindcss from 'tailwindcss' +import webpack from 'webpack' +import { merge } from 'webpack-merge' + +import checkNodeEnv from '../../scripts/check-node-env' +import { getAppInfoDefines } from './webpack.app-info' +import baseConfig from './webpack.config.base' +import webpackPaths from './webpack.paths' + +// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's +// at the dev webpack config is not accidentally run in a production environment +if (process.env.NODE_ENV === 'production') { + checkNodeEnv('development') +} + +const port = process.env.PORT || 1212 +const manifest = resolve(webpackPaths.dllPath, 'renderer.json') +const skipDLLs = + module.parent?.filename.includes('webpack.config.renderer.dev.dll') || + module.parent?.filename.includes('webpack.config.eslint') + +/** + * Warn if the DLL is not built + */ +if (!skipDLLs && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) { + console.log( + chalk.black.bgYellow.bold( + 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"', + ), + ) + execSync('npm run postinstall') +} + +interface ICustomConfiguration extends webpack.Configuration { + devServer?: object +} + +const configuration: ICustomConfiguration = { + devtool: 'inline-source-map', + + mode: 'development', + + target: ['web', 'electron-renderer'], + + entry: [ + `webpack-dev-server/client?http://localhost:${port}/dist`, + 'webpack/hot/only-dev-server', + join(webpackPaths.srcRendererPath, 'index.tsx'), + ], + + output: { + path: webpackPaths.distRendererPath, + publicPath: '/', + filename: 'renderer.dev.js', + library: { + type: 'umd', + }, + }, + + module: { + rules: [ + { + test: /\.s?(c|a)ss$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: true, + sourceMap: true, + importLoaders: 1, + }, + }, + 'sass-loader', + ], + include: /\.module\.s?(c|a)ss$/, + }, + { + test: /\.s?css$/, + use: [ + 'style-loader', + 'css-loader', + 'sass-loader', + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [tailwindcss, autoprefixer], + }, + }, + }, + ], + exclude: /\.module\.s?(c|a)ss$/, + }, + // Fonts + { + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: 'asset/resource', + }, + // Images + { + test: /\.(png|jpg|jpeg|gif)$/i, + type: 'asset/resource', + }, + // SVG + { + test: /\.svg$/, + use: [ + { + loader: '@svgr/webpack', + options: { + prettier: false, + svgo: false, + svgoConfig: { + plugins: [{ removeViewBox: false }], + }, + titleProp: true, + ref: true, + }, + }, + 'file-loader', + ], + }, + + { + test: /\.ts?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + ], + }, + + resolve: { + extensions: ['.ts', '.js'], + }, + plugins: [ + ...(skipDLLs + ? [] + : [ + new webpack.DllReferencePlugin({ + context: webpackPaths.dllPath, + manifest: require(manifest), + sourceType: 'var', + }), + ]), + + new webpack.NoEmitOnErrorsPlugin(), + + /** + * Create global constants which can be configured at compile time. + * + * Useful for allowing different behaviour between development builds and + * release builds + * + * NODE_ENV should be production so that modules do not perform certain + * development checks + * + * By default, use 'development' as NODE_ENV. This can be overriden with + * 'staging', for example, by changing the ENV variables in the npm scripts + */ + new webpack.EnvironmentPlugin({ + NODE_ENV: 'development', + }), + + new webpack.DefinePlugin({ + ...getAppInfoDefines(), + }), + + new webpack.LoaderOptionsPlugin({ + options: {}, + debug: true, + }), + + new ReactRefreshWebpackPlugin(), + + new HtmlWebpackPlugin({ + filename: join('index.html'), + template: join(webpackPaths.srcRendererPath, 'index.ejs'), + minify: { + collapseWhitespace: true, + removeAttributeQuotes: true, + removeComments: true, + }, + isBrowser: false, + env: process.env.NODE_ENV, + isDevelopment: process.env.NODE_ENV !== 'production', + nodeModules: webpackPaths.appNodeModulesPath, + }), + + new MonacoEditorWebpackPlugin({ + languages: ['python'], + }), + + new EslintPlugin({ + configType: 'flat', + extensions: ['ts', 'tsx'], + eslintPath: 'eslint/use-at-your-own-risk', + }), + ], + + node: { + __dirname: false, + __filename: false, + }, + + devServer: { + port, + compress: true, + hot: true, + headers: { 'Access-Control-Allow-Origin': '*' }, + static: { + publicPath: '/', + }, + historyApiFallback: { + verbose: true, + }, + setupMiddlewares(middlewares: never) { + console.log('Starting preload.js builder...') + const preloadProcess = spawn('npm', ['run', 'start:preload'], { + shell: true, + stdio: 'inherit', + }) + .on('close', (code: number) => process.exit(code)) + .on('error', (spawnError) => console.error(spawnError)) + + console.log('Starting Main Process...') + let args = ['run', 'start:main'] + if (process.env.MAIN_ARGS) { + args = args.concat(['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat()) + } + spawn('npm', args, { + shell: true, + stdio: 'inherit', + }) + .on('close', (code: number) => { + preloadProcess.kill() + process.exit(code) + }) + .on('error', (spawnError) => console.error(spawnError)) + return middlewares + }, + }, +} + +export default merge(baseConfig, configuration) diff --git a/configs/webpack/webpack.config.renderer.src2.dev.ts b/configs/webpack/webpack.config.renderer.src2.dev.ts deleted file mode 100644 index c44ebbccd..000000000 --- a/configs/webpack/webpack.config.renderer.src2.dev.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Webpack dev config for the src2/ migration renderer. - * - * Extends the standard renderer dev config but swaps the entry point and - * HTML template to use src2/ instead of src/renderer/. - * The main process, preload, DLL, and all build infrastructure remain identical. - * - * Usage: npm run start:src2 - */ - -import EslintPlugin from 'eslint-webpack-plugin' -import HtmlWebpackPlugin from 'html-webpack-plugin' -import { join } from 'path' -import webpack from 'webpack' -import { mergeWithCustomize, customizeArray } from 'webpack-merge' - -import rendererDevConfig from './webpack.config.renderer.dev' -import webpackPaths from './webpack.paths' - -const port = process.env.PORT || 1212 -const src2Path = join(webpackPaths.rootPath, 'src2') - -// Remove the base HtmlWebpackPlugin and EslintPlugin so we can replace/skip them -const basePlugins = (rendererDevConfig.plugins ?? []).filter( - (p) => !(p instanceof HtmlWebpackPlugin) && !(p instanceof EslintPlugin), -) -const baseConfig = { ...rendererDevConfig, plugins: basePlugins } - -const src2Overrides: webpack.Configuration = { - entry: [ - `webpack-dev-server/client?http://localhost:${port}/dist`, - 'webpack/hot/only-dev-server', - join(src2Path, 'main.tsx'), - ], - - resolve: { - alias: { - '@src2': join(webpackPaths.rootPath, 'src2'), - }, - }, - - plugins: [ - new HtmlWebpackPlugin({ - filename: 'index.html', - template: join(src2Path, 'index.ejs'), - minify: { - collapseWhitespace: true, - removeAttributeQuotes: true, - removeComments: true, - }, - isBrowser: false, - env: process.env.NODE_ENV, - isDevelopment: process.env.NODE_ENV !== 'production', - nodeModules: webpackPaths.appNodeModulesPath, - }), - ], -} - -export default mergeWithCustomize({ - customizeArray: customizeArray({ - entry: 'replace', - }), -})(baseConfig, src2Overrides) diff --git a/configs/webpack/webpack.paths.ts b/configs/webpack/webpack.paths.ts index edbcb5339..716e0c582 100644 --- a/configs/webpack/webpack.paths.ts +++ b/configs/webpack/webpack.paths.ts @@ -4,7 +4,7 @@ const rootPath = join(__dirname, '../..') const dllPath = join(__dirname, '../dll') -const srcPath = join(rootPath, 'src') +const srcPath = join(rootPath, 'src_old') const srcMainPath = join(srcPath, 'main') const srcRendererPath = join(srcPath, 'renderer') @@ -20,7 +20,7 @@ const distRendererPath = join(distPath, 'renderer') const buildPath = join(releasePath, 'build') -const typesPath = join(srcPath, 'types') +const typesPath = join(rootPath, 'src', 'types') export default { rootPath, diff --git a/docs/dead-code-inventory.md b/docs/dead-code-inventory.md new file mode 100644 index 000000000..803a9315d --- /dev/null +++ b/docs/dead-code-inventory.md @@ -0,0 +1,69 @@ +# Dead Code Inventory + +Audit date: 2026-03-17 +Branch: `fix/step-31-final-adjustments` + +This document tracks dead code identified during a code quality audit. +Items here are documented for later cleanup — they are not blocking any work. + +--- + +## Unused Exported Function + +### `getVariableSize()` — `src/frontend/utils/variable-sizes.ts:101-146` + +Exported function that takes a full `PLCVariable` object and returns byte size. +Superseded by `getTypeSizeByName()` (line 152) which takes just a type name string. + +- Never imported by any other file in the codebase. +- Not called internally within the file. +- The sibling function `getTypeSizeByName` covers the same logic and is actively used. + +**Action:** Remove the function. No callers to update. + +--- + +## Unnecessary Export Keyword + +### `parseVariableValue()` — `src/frontend/utils/variable-sizes.ts:189-258` + +Exported but only called internally by `parseValueByTypeName()` (line 277). +No external file imports `parseVariableValue` directly. + +**Action:** Remove the `export` keyword. The function itself is live code (used by `parseValueByTypeName` which is consumed by `useDebugPolling.ts`). + +--- + +## Commented-Out Imports + +These are leftover commented imports that should be removed: + +| File | Line | Content | +|------|------|---------| +| `src/main/modules/preload/preload.ts` | 1 | `// import './splash-screen/index'` | +| `src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts` | 4 | `// import type { VariableNode } from '...'` | +| `src/types/common/project.ts` | 1, 3 | `// import { z } from 'zod'`, `// import { CONSTANTS, formatDate } from '@/utils'` | +| `src/frontend/utils/PLC/xml-generator/codesys/pou-xml.ts` | 1 | `// import { PLCVariable } from '@root/types/PLC'` | +| `src/frontend/components/_organisms/global-variables-editor/index.tsx` | 1 | `// import * as PrimitiveSwitch from '@radix-ui/react-switch'` | + +**Action:** Delete each commented line. + +--- + +## TODO/FIXME Comments (Incomplete Implementations) + +These are not dead code but flag missing validation logic: + +| File | Description | +|------|-------------| +| `src/types/PLC/open-plc.ts:165-168` | Task name uniqueness — needs homologation | +| `src/types/PLC/open-plc.ts:234-236` | Schema validation TODOs | +| `src/types/PLC/units/task.ts` | Task interval regex validation missing | +| `src/types/PLC/units/instance.ts` | Instance task/program validation missing | +| `src/types/PLC/units/library.ts` | Library validation TODOs | +| `src/backend/editor/hardware/hardware-module.ts` | TODO comment | +| `src/backend/editor/compiler/compiler-module.ts` | TODO comment | +| `src/backend/editor/services/project-service/utils/read-project.ts` | TODO comment | +| `src/main/main.ts` | Multiple TODO comments | + +**Action:** Track as separate backlog items, not dead code cleanup. diff --git a/eslint.config.mjs b/eslint.config.mjs index 94db363bf..04d3a3748 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,7 +9,7 @@ import tseslint from 'typescript-eslint' /** @type {import('@types/eslint').Linter.FlatConfig} */ export default [ { - files: ['src/**/*.ts', 'src/**/*.tsx'], + files: ['src/**/*.ts', 'src/**/*.tsx', 'src_old/**/*.ts', 'src_old/**/*.tsx'], ignores: [ '**/*.d.ts', '**/*.test.{js,jsx,ts,tsx}', @@ -59,7 +59,7 @@ export default [ ...reactRecommended, name: 'eslint-plugin-react', files: ['**/*.{js,jsx,ts,tsx}'], - ignores: ['src/main/**/*'], + ignores: ['src_old/main/**/*'], languageOptions: { parserOptions: { project: true, diff --git a/jest.config.json b/jest.config.json index 6aaa26da5..4de4b15d0 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,33 +1,76 @@ { - "setupFiles": ["/jest.setup.js"], - "modulePathIgnorePatterns": ["/release/"], - "moduleDirectories": ["node_modules", "release/app/node_modules"], + "displayName": "src", + "rootDir": ".", + "roots": ["/src"], + "setupFiles": ["/src/jest-vi-shim.ts"], + "moduleDirectories": ["node_modules"], "moduleFileExtensions": ["js", "json", "jsx", "ts", "tsx"], "moduleNameMapper": { - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/configs/mocks/fileMock.js", - "\\.(css|less|sass|scss)$": "identity-obj-proxy", "^@root/(.*)$": "/src/$1", - "^@process:renderer/(.*)$": "/src/renderer/$1", - "^@process:main/(.*)$": "/src/main/$1" + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/configs/mocks/fileMock.js", + "\\.(css|less|sass|scss)$": "identity-obj-proxy" }, - "testEnvironment": "node", + "testEnvironment": "jsdom", "testEnvironmentOptions": { "url": "http://localhost/" }, - "testMatch": ["**/?(*.)+(spec|test).(ts|tsx|js|jsx)", "**/__tests__/**/*.(ts|tsx|js|jsx)"], - "testPathIgnorePatterns": ["release/app/dist", "configs/dll", "e2e", "src2"], + "testMatch": [ + "/src/**/?(*.)+(spec|test).(ts|tsx)", + "/src/**/__tests__/**/*.(ts|tsx)" + ], "transform": { - "\\.(ts|tsx|js|jsx)$": [ - "ts-jest", - { - "tsconfig": "tsconfig.json" - } - ], - "^.+.tsx?$": [ + "\\.(ts|tsx)$": [ "ts-jest", { "tsconfig": "tsconfig.json" } ] + }, + "collectCoverageFrom": [ + "src/**/*.{ts,tsx}", + "!src/**/*.d.ts", + "!src/**/__tests__/**", + "!src/**/*.{test,spec}.{ts,tsx}", + "!src/__architecture__/**", + "!src/**/index.ts", + "!src/middleware/shared/ports/*.ts", + "!src/middleware/shared/providers/types.ts", + "!src/middleware/adapters/**", + "!src/middleware/editor-platform.ts", + "!src/App.tsx", + "!src/main.tsx", + "!src/backend/styles/**", + "!src/backend/shared-data/**", + "!src/frontend/locales/**", + "!src/frontend/components/**", + "!src/frontend/assets/**", + "!src/frontend/data/**", + "!src/frontend/hooks/**", + "!src/frontend/services/**", + "!src/frontend/screens/**", + "!src/frontend/store/index.ts", + "!src/frontend/store/slices/project/validation/**", + "!src/frontend/utils/generate-uuid.ts", + "!src/frontend/utils/variable-types.ts", + "!src/frontend/utils/variable-reference-types.ts", + "!src/frontend/utils/hex.ts", + "!src/frontend/utils/debug-parser.ts", + "!src/frontend/utils/debug-tree-builder.ts", + "!src/frontend/utils/debug-tree-traversal.ts", + "!src/frontend/utils/debug-variable-finder.ts", + "!src/frontend/utils/pou-helpers.ts", + "!src/frontend/utils/variable-sizes.ts", + "!src/backend/shared/array-variable-utils.ts", + "!src/jest-vi-shim.ts" + ], + "coverageDirectory": "coverage/src", + "coverageReporters": ["text", "text-summary", "lcov", "json-summary"], + "coverageThreshold": { + "global": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 + } } } diff --git a/jest.config.old.json b/jest.config.old.json new file mode 100644 index 000000000..c821bfc9a --- /dev/null +++ b/jest.config.old.json @@ -0,0 +1,34 @@ +{ + "setupFiles": ["/jest.setup.js"], + "modulePathIgnorePatterns": ["/release/"], + "moduleDirectories": ["node_modules", "release/app/node_modules"], + "moduleFileExtensions": ["js", "json", "jsx", "ts", "tsx"], + "moduleNameMapper": { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/configs/mocks/fileMock.js", + "\\.(css|less|sass|scss)$": "identity-obj-proxy", + "^@root/(.*)$": "/src_old/$1", + "^@process:renderer/(.*)$": "/src_old/renderer/$1", + "^@process:main/(.*)$": "/src_old/main/$1" + }, + "testEnvironment": "node", + "testEnvironmentOptions": { + "url": "http://localhost/" + }, + "roots": ["/src_old"], + "testMatch": ["**/?(*.)+(spec|test).(ts|tsx|js|jsx)", "**/__tests__/**/*.(ts|tsx|js|jsx)"], + "testPathIgnorePatterns": ["release/app/dist", "configs/dll", "e2e"], + "transform": { + "\\.(ts|tsx|js|jsx)$": [ + "ts-jest", + { + "tsconfig": "tsconfig.json" + } + ], + "^.+.tsx?$": [ + "ts-jest", + { + "tsconfig": "tsconfig.json" + } + ] + } +} diff --git a/jest.config.src2.json b/jest.config.src2.json deleted file mode 100644 index 81c4ddf54..000000000 --- a/jest.config.src2.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "displayName": "src2", - "rootDir": ".", - "roots": ["/src2"], - "setupFiles": ["/src2/jest-vi-shim.ts"], - "moduleDirectories": ["node_modules"], - "moduleFileExtensions": ["js", "json", "jsx", "ts", "tsx"], - "moduleNameMapper": { - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/configs/mocks/fileMock.js", - "\\.(css|less|sass|scss)$": "identity-obj-proxy" - }, - "testEnvironment": "jsdom", - "testEnvironmentOptions": { - "url": "http://localhost/" - }, - "testMatch": [ - "/src2/**/?(*.)+(spec|test).(ts|tsx)", - "/src2/**/__tests__/**/*.(ts|tsx)" - ], - "transform": { - "\\.(ts|tsx)$": [ - "ts-jest", - { - "tsconfig": "tsconfig.json" - } - ] - }, - "collectCoverageFrom": [ - "src2/**/*.{ts,tsx}", - "!src2/**/*.d.ts", - "!src2/**/__tests__/**", - "!src2/**/*.{test,spec}.{ts,tsx}", - "!src2/__architecture__/**", - "!src2/**/index.ts", - "!src2/middleware/shared/ports/*.ts", - "!src2/middleware/shared/providers/types.ts", - "!src2/middleware/adapters/**", - "!src2/middleware/editor-platform.ts", - "!src2/App.tsx", - "!src2/main.tsx", - "!src2/backend/styles/**", - "!src2/backend/shared-data/**", - "!src2/frontend/locales/**", - "!src2/frontend/components/**", - "!src2/frontend/assets/**", - "!src2/frontend/data/**", - "!src2/frontend/hooks/**", - "!src2/frontend/services/**", - "!src2/frontend/screens/**", - "!src2/frontend/store/index.ts", - "!src2/frontend/store/slices/project/validation/**", - "!src2/frontend/utils/generate-uuid.ts", - "!src2/frontend/utils/variable-types.ts", - "!src2/frontend/utils/variable-reference-types.ts", - "!src2/frontend/utils/hex.ts", - "!src2/frontend/utils/debug-parser.ts", - "!src2/frontend/utils/debug-tree-builder.ts", - "!src2/frontend/utils/debug-tree-traversal.ts", - "!src2/frontend/utils/debug-variable-finder.ts", - "!src2/frontend/utils/pou-helpers.ts", - "!src2/frontend/utils/variable-sizes.ts", - "!src2/backend/shared/array-variable-utils.ts", - "!src2/jest-vi-shim.ts" - ], - "coverageDirectory": "coverage/src2", - "coverageReporters": ["text", "text-summary", "lcov", "json-summary"], - "coverageThreshold": { - "global": { - "branches": 100, - "functions": 100, - "lines": 100, - "statements": 100 - } - } -} diff --git a/package.json b/package.json index 30ebd57a4..4e391e115 100644 --- a/package.json +++ b/package.json @@ -14,24 +14,29 @@ "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.renderer.prod.ts", "lint": "cross-env NODE_ENV=development eslint ./src/**/*.{ts,tsx}", "lint:fix": "cross-env NODE_ENV=development eslint ./src/**/*.{ts,tsx} --debug --fix", + "lint:old": "cross-env NODE_ENV=development eslint ./src_old/**/*.{ts,tsx}", + "lint:old:fix": "cross-env NODE_ENV=development eslint ./src_old/**/*.{ts,tsx} --debug --fix", "format": "cross-env NODE_ENV=development prettier --write \"./src/**/*.{ts,tsx}\"", + "format:old": "cross-env NODE_ENV=development prettier --write \"./src_old/**/*.{ts,tsx}\"", "postinstall": "ts-node scripts/download-binaries.ts && ts-node scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll", "setup:binaries": "ts-node scripts/download-binaries.ts", "package": "ts-node scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app", "prestart": "ts-node scripts/download-binaries.ts && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.main.dev.ts", - "start:dev": "ts-node scripts/check-port-in-use.js && npm run prestart && npm run start:renderer", - "start:main": "concurrently -k \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./configs/webpack/webpack.config.main.dev.ts\" \"electronmon .\"", - "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.preload.dev.ts", + "start:dev": "cross-env PORT=1313 ts-node scripts/check-port-in-use.js && npm run prestart && npm run start:renderer", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack/webpack.config.renderer.dev.ts", - "start:src2": "ts-node scripts/check-port-in-use.js && npm run prestart && npm run start:renderer:src2", - "start:renderer:src2": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack/webpack.config.renderer.src2.dev.ts", - "test:unit": "jest --watch --no-coverage", + "start:electron": "cross-env PORT=1313 ts-node scripts/check-port-in-use.js && npm run prestart && concurrently -k \"npm run start:renderer\" \"cross-env PORT=1313 electronmon .\"", + "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.preload.dev.ts", + "prestart:old": "ts-node scripts/download-binaries.ts && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./configs/webpack/webpack.config.main.old.dev.ts", + "start:dev:old": "ts-node scripts/check-port-in-use.js && npm run prestart:old && npm run start:renderer:old", + "start:main:old": "concurrently -k \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./configs/webpack/webpack.config.main.old.dev.ts\" \"electronmon .\"", + "start:renderer:old": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./configs/webpack/webpack.config.renderer.old.dev.ts", + "test": "jest --collectCoverage", + "test:watch": "jest --watch --no-coverage", + "test:old": "jest --config jest.config.old.json --collectCoverage --", + "test:old:unit": "jest --config jest.config.old.json --watch --no-coverage", "test:e2e": "npm run build && npx playwright test", - "test": "jest --collectCoverage --", - "test:src2": "jest --config jest.config.src2.json --collectCoverage", - "test:src2:watch": "jest --config jest.config.src2.json --watch --no-coverage", - "validate:arch": "npx tsx src2/__architecture__/validate.ts", + "validate:arch": "npx tsx src/__architecture__/validate.ts", "prepare": "husky" }, "browserslist": [], @@ -183,7 +188,8 @@ "!node_modules", "!node_modules/**/*", "!.*", - "src/**/*" + "src/**/*", + "src_old/**/*" ], "logLevel": "quiet" }, @@ -191,6 +197,10 @@ "./src/**/*": [ "npm run lint:fix", "npm run format" + ], + "./src_old/**/*": [ + "npm run lint:old:fix", + "npm run format:old" ] } } diff --git a/scripts/fix-import-depths.ts b/scripts/fix-import-depths.ts index a73e35dc0..748d88c2c 100644 --- a/scripts/fix-import-depths.ts +++ b/scripts/fix-import-depths.ts @@ -1,6 +1,6 @@ #!/usr/bin/env npx tsx /** - * Fix relative import paths in src2/ that have incorrect depth (wrong number of ../ segments). + * Fix relative import paths in src/ that have incorrect depth (wrong number of ../ segments). * * For each relative import, resolves it from the importing file's directory. * If the target doesn't exist, tries removing or adding ../ segments until it resolves. @@ -12,7 +12,7 @@ import * as fs from 'fs' import * as path from 'path' -const SRC2_DIR = path.resolve(__dirname, '..', 'src2') +const SRC_DIR = path.resolve(__dirname, '..', 'src') const DRY_RUN = process.argv.includes('--dry-run') const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'] const RESOLVE_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js', '/index.jsx'] @@ -71,9 +71,9 @@ function fixImportPath(fromDir: string, importPath: string): string | null { for (let add = 1; add <= 5; add++) { const newDotDots = dotdotCount + add const candidate = [...Array(newDotDots).fill('..'), ...tail].join('/') - // Safety: don't escape the src2 root + // Safety: don't escape the src root const resolved = path.resolve(fromDir, candidate) - if (!resolved.startsWith(SRC2_DIR)) break + if (!resolved.startsWith(SRC_DIR)) break if (resolveImport(fromDir, candidate)) { return candidate } @@ -89,8 +89,8 @@ const IMPORT_RE = /(?:from\s+|import\s*\(?\s*)(['"])(\.\.?\/[^'"]+)\1/g let totalFixes = 0 let totalFiles = 0 -const files = getAllFiles(SRC2_DIR) -console.log(`Scanning ${files.length} files in src2/...`) +const files = getAllFiles(SRC_DIR) +console.log(`Scanning ${files.length} files in src/...`) for (const filePath of files) { const content = fs.readFileSync(filePath, 'utf-8') diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 000000000..3068e21b4 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,74 @@ +import '@xyflow/react/dist/style.css' +import 'tailwindcss/tailwind.css' +import './backend/styles/globals.css' + +import { useEffect } from 'react' + +import { AppLayout } from './frontend/components/_templates/app-layout' +import { + AdditionalFunctionBlocks, + ArduinoFunctionBlocks, + Arithmetic, + BitShift, + Bitwise, + CharacterString, + CommunicationBlocks, + Comparison, + Jaguar, + MQTT, + Numerical, + P1AM, + Selection, + SequentMicrosystemsModules, + StandardFunctionBlocks, + Time, + TypeConversion, +} from './frontend/data/library' +import { StartScreen } from './frontend/screens/start-screen' +import { WorkspaceScreen } from './frontend/screens/workspace-screen' +import { openPLCStoreBase, useOpenPLCStore } from './frontend/store' +import { editorPorts, setRuntimeIpAddress } from './middleware/editor-platform' +import { PlatformProvider } from './middleware/shared/providers' + +// Initialize system libraries at module load time (before first render) +openPLCStoreBase + .getState() + .libraryActions.setSystemLibraries([ + AdditionalFunctionBlocks, + ArduinoFunctionBlocks, + CommunicationBlocks, + Jaguar, + MQTT, + P1AM, + SequentMicrosystemsModules, + StandardFunctionBlocks, + Arithmetic, + BitShift, + Bitwise, + CharacterString, + Comparison, + Numerical, + Selection, + Time, + TypeConversion, + ]) + +export default function App() { + const { + project: { + meta: { path }, + }, + } = useOpenPLCStore() + + // Sync store runtime IP to the platform adapter so the runtime port can access it + const runtimeIpAddress = useOpenPLCStore((state) => state.deviceDefinitions.configuration.runtimeIpAddress || '') + useEffect(() => { + setRuntimeIpAddress(runtimeIpAddress) + }, [runtimeIpAddress]) + + return ( + + {path === '' ? : } + + ) +} diff --git a/src2/__architecture__/validate.ts b/src/__architecture__/validate.ts similarity index 78% rename from src2/__architecture__/validate.ts rename to src/__architecture__/validate.ts index f31f8c93e..dcca3c405 100644 --- a/src2/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -1,10 +1,10 @@ /** - * Architecture validation script for the src2/ migration. + * Architecture validation script. * - * Scans all .ts/.tsx files in src2/, extracts imports, and validates + * Scans all .ts/.tsx files in src/, extracts imports, and validates * that dependency rules between architectural layers are respected. * - * Run: npx tsx src2/__architecture__/validate.ts + * Run: npx tsx src/__architecture__/validate.ts * Exit code 0 = pass, 1 = violations found */ @@ -31,14 +31,14 @@ type LayerName = interface LayerRule { /** Human-readable layer name */ name: string - /** Layers this layer is allowed to import from (within src2/) */ + /** Layers this layer is allowed to import from (within src/) */ allowedDeps: LayerName[] } const LAYER_RULES: Record = { utils: { name: 'Domain (frontend/utils/)', - allowedDeps: ['utils'], + allowedDeps: ['utils', 'ports', 'data'], }, data: { name: 'Data (frontend/data/)', @@ -54,7 +54,7 @@ const LAYER_RULES: Record = { }, adapters: { name: 'Adapters (middleware/adapters/)', - allowedDeps: ['ports', 'provider', 'utils'], + allowedDeps: ['ports', 'provider', 'utils', 'backend-shared'], }, 'backend-shared': { name: 'Backend Shared (backend/shared/)', @@ -62,19 +62,19 @@ const LAYER_RULES: Record = { }, store: { name: 'Store (frontend/store/)', - allowedDeps: ['ports', 'provider', 'store', 'utils', 'backend-shared'], + allowedDeps: ['ports', 'provider', 'store', 'utils'], }, services: { name: 'Services (frontend/services/)', - allowedDeps: ['ports', 'provider', 'store', 'services', 'utils', 'backend-shared'], + allowedDeps: ['ports', 'provider', 'store', 'services', 'utils'], }, hooks: { name: 'Hooks (frontend/hooks/)', - allowedDeps: ['ports', 'provider', 'store', 'hooks', 'services', 'utils', 'backend-shared'], + allowedDeps: ['ports', 'provider', 'store', 'hooks', 'services', 'utils'], }, components: { name: 'Components (frontend/components/)', - allowedDeps: ['ports', 'provider', 'store', 'hooks', 'services', 'components', 'data', 'utils', 'backend-shared'], + allowedDeps: ['ports', 'provider', 'store', 'hooks', 'services', 'components', 'data', 'utils'], }, architecture: { name: 'Architecture (__architecture__/)', @@ -86,7 +86,8 @@ const LAYER_RULES: Record = { // Helpers // --------------------------------------------------------------------------- -const SRC2_ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..') +// @ts-expect-error TS1343 — this script runs via `npx tsx` (ESM), not through webpack +const SRC_ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..') function collectFiles(dir: string, ext: string[]): string[] { const results: string[] = [] @@ -104,7 +105,7 @@ function collectFiles(dir: string, ext: string[]): string[] { /** Determine which architectural layer a file belongs to */ function getLayer(filePath: string): LayerName | null { - const rel = relative(SRC2_ROOT, filePath).replace(/\\/g, '/') + const rel = relative(SRC_ROOT, filePath).replace(/\\/g, '/') if (rel.startsWith('__architecture__/')) return 'architecture' @@ -162,14 +163,14 @@ function extractImports(source: string): { path: string; line: number }[] { /** Resolve a relative import path to an absolute file path (best-effort) */ function resolveImport(importPath: string, fromFile: string): string | null { - // Only check relative imports (within src2/) + // Only check relative imports (within src/) if (!importPath.startsWith('.')) return null const dir = dirname(fromFile) const resolved = resolve(dir, importPath) - // Check if it resolves to something inside src2/ - if (!resolved.startsWith(SRC2_ROOT)) return null + // Check if it resolves to something inside src/ + if (!resolved.startsWith(SRC_ROOT)) return null return resolved } @@ -183,22 +184,17 @@ function resolveImport(importPath: string, fromFile: string): string | null { * functionality (e.g., sync utilities that bridge store types with component * types, or store helpers that reference component node builders). * - * Each entry maps a file path (relative to SRC2_ROOT, forward-slash separated) + * Each entry maps a file path (relative to SRC_ROOT, forward-slash separated) * to the set of extra layers it is allowed to import from beyond what its own * layer rule permits. */ const KNOWN_EXCEPTIONS: Record = { - // Syncs React Flow nodes with PLC variables — needs store types, port types, and component constants - 'frontend/utils/graphical/sync-nodes-with-variables.ts': ['ports', 'store', 'components'], - // Determines which FB variables to clean up — needs port types and component block types - 'frontend/utils/graphical/get-function-block-variables-to-cleanup.ts': ['ports', 'components'], - // FBD paste/duplicate helpers — needs component atom types and molecule node builders + // FBD paste/duplicate helpers — needs molecule-level buildGenericNode from components 'frontend/store/slices/fbd/utils/index.ts': ['components'], - // Debug utilities need port types, store types, and library data for variable resolution - 'frontend/utils/variable-sizes.ts': ['ports'], - 'frontend/utils/pou-helpers.ts': ['ports', 'data', 'store'], - 'frontend/utils/debug-tree-traversal.ts': ['ports', 'data', 'store'], - 'frontend/utils/debug-tree-builder.ts': ['ports', 'store'], + // Ladder paste/duplicate helpers — needs nodesBuilder from component atoms + 'frontend/store/slices/ladder/utils/index.ts': ['components'], + // Ladder slice — needs nodesBuilder + defaultCustomNodesStyles for rung creation + 'frontend/store/slices/ladder/slice.ts': ['components'], } // --------------------------------------------------------------------------- @@ -217,7 +213,7 @@ interface Violation { function validate(): Violation[] { const violations: Violation[] = [] - const files = collectFiles(SRC2_ROOT, ['.ts', '.tsx']).filter( + const files = collectFiles(SRC_ROOT, ['.ts', '.tsx']).filter( (f) => !f.includes('__architecture__/') && !f.includes('__tests__/') && !f.match(/\.(test|spec)\.[jt]sx?$/), ) @@ -227,17 +223,17 @@ function validate(): Violation[] { const source = readFileSync(file, 'utf-8') const imports = extractImports(source) - const relFile = relative(SRC2_ROOT, file).replace(/\\/g, '/') + const relFile = relative(SRC_ROOT, file).replace(/\\/g, '/') const exceptions = KNOWN_EXCEPTIONS[relFile] ?? [] for (const imp of imports) { const resolved = resolveImport(imp.path, file) - if (!resolved) continue // External or non-src2 import — skip + if (!resolved) continue // External or non-src import — skip const toLayer = getLayer(resolved) if (!toLayer) continue // Can't determine target layer — skip - const rule = LAYER_RULES[fromLayer] + const rule: LayerRule = LAYER_RULES[fromLayer] if (!rule.allowedDeps.includes(toLayer) && fromLayer !== toLayer && !exceptions.includes(toLayer)) { violations.push({ file: relFile, diff --git a/src2/__tests__/platform-context.test.tsx b/src/__tests__/platform-context.test.tsx similarity index 96% rename from src2/__tests__/platform-context.test.tsx rename to src/__tests__/platform-context.test.tsx index 5367f1fc4..bab0685f5 100644 --- a/src2/__tests__/platform-context.test.tsx +++ b/src/__tests__/platform-context.test.tsx @@ -1,6 +1,7 @@ import { renderHook } from '@testing-library/react' import { type ReactNode } from 'react' +import { EDITOR_CAPABILITIES } from '../middleware/shared/ports/platform-capabilities' import { PlatformProvider, useAccelerator, @@ -17,7 +18,6 @@ import { useWindow, } from '../middleware/shared/providers' import type { PlatformPorts } from '../middleware/shared/providers/types' -import { EDITOR_CAPABILITIES } from '../middleware/shared/ports/platform-capabilities' function createStubPort(): T { return new Proxy({} as T, { @@ -35,6 +35,7 @@ const testPorts: PlatformPorts = { simulator: createStubPort(), project: createStubPort(), device: createStubPort(), + orchestrator: createStubPort(), system: createStubPort(), window: createStubPort(), accelerator: createStubPort(), @@ -55,9 +56,7 @@ describe('PlatformProvider', () => { it('throws when usePlatform is used outside PlatformProvider', () => { // Suppress expected React error boundary console output const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) - expect(() => renderHook(() => usePlatform())).toThrow( - 'usePlatform must be used within a PlatformProvider', - ) + expect(() => renderHook(() => usePlatform())).toThrow('usePlatform must be used within a PlatformProvider') spy.mockRestore() }) }) diff --git a/src/main/modules/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts similarity index 100% rename from src/main/modules/compiler/compiler-module.spec.ts rename to src/backend/editor/compiler/compiler-module.spec.ts diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts new file mode 100644 index 000000000..a52bd83ab --- /dev/null +++ b/src/backend/editor/compiler/compiler-module.ts @@ -0,0 +1,2335 @@ +import { exec, spawn } from 'node:child_process' +import { promises as fs } from 'node:fs' +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' +import type { IncomingMessage } from 'node:http' +import https from 'node:https' +import os from 'node:os' +import path from 'node:path' +import { join } from 'node:path' +import { promisify } from 'node:util' + +import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import type { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' +import type { PLCProjectData } from '@root/types/PLC/open-plc' +import { type CppPouData as CppPouDataCode, generateCBlocksCode } from '@root/utils/cpp/generateCBlocksCode' +import { type CppPouData as CppPouDataHeader, generateCBlocksHeader } from '@root/utils/cpp/generateCBlocksHeader' +import { getErrorMessage } from '@root/utils/get-error-message' +import { generateModbusMasterConfig } from '@root/utils/modbus/generate-modbus-master-config' +import { generateModbusSlaveConfig } from '@root/utils/modbus/generate-modbus-slave-config' +import { generateOpcUaConfig, OpcUaConfigError } from '@root/utils/opcua' +import { XmlGenerator } from '@root/utils/PLC/xml-generator' +import { parsePlcStatus } from '@root/utils/plc-status' +import { generateS7CommConfig } from '@root/utils/s7comm' +import { app as electronApp, dialog } from 'electron' +import type { MessagePortMain } from 'electron/main' +import JSZip from 'jszip' + +import { CreateXMLFile } from '../utils' +import type { ArduinoCoreControl, HalsFile } from './compiler-types' +import { FormatMacAddress } from './utils/formatters' + +interface MethodsResult { + success: boolean + data?: T +} +type HandleOutputDataCallback = (chunk: Buffer | string, logLevel?: 'info' | 'error') => void + +type CompileArduinoProgramArgs = { + boardTarget: string + boardHalsContent: HalsFile[string] + compilationPath: string + handleOutputData: HandleOutputDataCallback +} + +class CompilerModule { + binaryDirectoryPath: string + sourceDirectoryPath: string + halsFilePath: string + + arduinoCliBinaryPath: string + arduinoCliConfigurationFilePath: string + arduinoCliBaseParameters: string[] + + xml2stBinaryPath: string + + iec2cBinaryPath: string + + // ############################################################################ + // =========================== Static properties ============================== + // ############################################################################ + static readonly HOST_PLATFORM = process.platform + static readonly HOST_ARCHITECTURE = process.arch + static readonly DEVELOPMENT_MODE = process.env.NODE_ENV === 'development' + // This will later be replaced by platform specific libraries + static readonly GLOBAL_LIBRARIES = [ + 'Arduino_EdgeControl', + 'ArduinoJson', + 'Arduino_MachineControl', + 'ArduinoMqttClient', + 'AVR_PWM', + 'CAN', + 'CONTROLLINO', + 'DallasTemperature', + 'Ethernet', + 'megaAVR_PWM', + 'OneWire', + 'P1AM', + 'Portenta_H7_PWM', + 'PubSubClient', + 'RP2040_PWM', + 'SAMD_PWM', + 'SAMDUE_PWM', + 'STM32_CAN', + 'STM32_PWM', + 'WiFiNINA', + ] + + // Runtime API polling configuration (important-comment) + static readonly COMPILATION_STATUS_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes (important-comment) + static readonly COMPILATION_STATUS_POLL_INTERVAL_MS = 1000 // 1 second (important-comment) + + constructor() { + this.binaryDirectoryPath = this.#constructBinaryDirectoryPath() + this.sourceDirectoryPath = this.#constructSourceDirectoryPath() + this.halsFilePath = this.#constructHalsFilePath() + + this.arduinoCliBinaryPath = this.#constructArduinoCliBinaryPath() + this.arduinoCliConfigurationFilePath = join(electronApp.getPath('userData'), 'User', 'arduino-cli.yaml') + // INFO: We use this approach because some commands can receive additional parameters as a string array. + this.arduinoCliBaseParameters = ['--config-file', this.arduinoCliConfigurationFilePath] + + this.xml2stBinaryPath = this.#constructXml2stBinaryPath() + + this.iec2cBinaryPath = this.#constructIec2cBinaryPath() + } + + // ############################################################################ + // =========================== Static methods ================================= + // ############################################################################ + static async readJSONFile(filePath: string): Promise { + const data = await readFile(filePath, 'utf-8') + return JSON.parse(data) as T + } + + // ############################################################################ + // =========================== Private methods ================================ + // ############################################################################ + + private parseLogLevel(message: string): { level: 'info' | 'warning' | 'error'; cleanedMessage: string } { + const logLevelMatch = message.match(/^\[(INFO|WARNING|ERROR)\]\s*/) + + if (logLevelMatch) { + const level = logLevelMatch[1].toLowerCase() as 'info' | 'warning' | 'error' + const cleanedMessage = message.replace(/^\[(INFO|WARNING|ERROR)\]\s*/, '') + return { + level, + cleanedMessage, + } + } + + return { + level: 'info', + cleanedMessage: message, + } + } + + // Initialize paths based on the environment + #constructBinaryDirectoryPath(): string { + if (CompilerModule.HOST_ARCHITECTURE !== 'x64' && CompilerModule.HOST_ARCHITECTURE !== 'arm64') return '' + const platformSpecificPath = join(CompilerModule.HOST_PLATFORM, CompilerModule.HOST_ARCHITECTURE) + return join( + CompilerModule.DEVELOPMENT_MODE ? process.cwd() : process.resourcesPath, + CompilerModule.DEVELOPMENT_MODE ? 'resources' : '', + 'bin', + CompilerModule.DEVELOPMENT_MODE ? platformSpecificPath : '', + ) + } + + #constructSourceDirectoryPath(): string { + return join( + CompilerModule.DEVELOPMENT_MODE ? process.cwd() : process.resourcesPath, + CompilerModule.DEVELOPMENT_MODE ? 'resources' : '', + 'sources', + ) + } + + #constructHalsFilePath(): string { + return join( + CompilerModule.DEVELOPMENT_MODE ? process.cwd() : process.resourcesPath, + CompilerModule.DEVELOPMENT_MODE ? 'resources' : '', + 'sources', + 'boards', + 'hals.json', + ) + } + + #constructArduinoCliBinaryPath(): string { + return join(this.binaryDirectoryPath, 'arduino-cli') + } + + #constructXml2stBinaryPath(): string { + return join(this.binaryDirectoryPath, 'xml2st', CompilerModule.HOST_PLATFORM === 'darwin' ? 'xml2st' : '') + } + + #constructIec2cBinaryPath(): string { + return join(this.binaryDirectoryPath, 'iec2c') + } + + async #getBoardRuntime(board: string) { + const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) + return halsFileContent[board]['compiler'] + } + + #executeXml2st(args: string[]) { + let xml2stBinaryPath = this.xml2stBinaryPath + if (CompilerModule.HOST_PLATFORM === 'win32') { + xml2stBinaryPath += '.exe' + } + return spawn(xml2stBinaryPath, args) + } + + #executeArduinoCliCommand(args: string[]) { + let arduinoCliBinaryPath = this.arduinoCliBinaryPath + if (CompilerModule.HOST_PLATFORM === 'win32') { + arduinoCliBinaryPath += '.exe' + } + return spawn(arduinoCliBinaryPath, args) + } + + // ############################################################################ + // =========================== Public methods ================================= + // ############################################################################ + + // ++ ========================= Utility methods ============================= ++ + + getHostHardwareInfo() { + return ` + System Architecture - ${process.arch} + Operating System - ${process.platform} + Processor - ${process.env.PROCESSOR_IDENTIFIER} + Logical CPU Cores - ${os.cpus().length} + CPU Frequency - ${os.cpus()[0].speed} MHz + CPU Model - ${os.cpus()[0].model} + ` + } + + async checkArduinoCliAvailability(): Promise> { + let binaryPath = this.arduinoCliBinaryPath + const [flag, configFilePath] = this.arduinoCliBaseParameters + const executeCommand = promisify(exec) + + if (CompilerModule.HOST_PLATFORM === 'win32') { + // INFO: On Windows, we need to add the .exe extension to the binary path. + binaryPath += '.exe' + } + // INFO: We use the version command to check if the arduino-cli is available. + // INFO: If the command is not available, it will throw an error. + const { stdout, stderr } = await executeCommand(`"${binaryPath}" version ${flag} "${configFilePath}" --json`) + if (stderr) { + throw new Error(`Arduino CLI not available: ${stderr}`) + } + + /** + * Parses the JSON output from the Arduino CLI. + * @example The output will be like: + * { + * "Application": "arduino-cli", + * "VersionString": "x.y.z", + * "Commit": "commit-hash", + * "Status": "version-status", + * "Date": "release-date", + * } + * @updatedAt 17/07/2025 + */ + const stdoutAsJsonObject = JSON.parse(stdout) as Record + + const { VersionString } = stdoutAsJsonObject + + return { success: true, data: VersionString } + } + + async checkIec2cAvailability(): Promise> { + let binaryPath = this.iec2cBinaryPath + const executeCommand = promisify(exec) + + if (CompilerModule.HOST_PLATFORM === 'win32') { + // INFO: On Windows, we need to add the .exe extension to the binary path. + binaryPath += '.exe' + } + // INFO: We use the version command to check if the iec2c is available. + // INFO: If the command is not available, it will throw an error. + const { stdout, stderr } = await executeCommand(`"${binaryPath}" -v`) + if (stderr) { + throw new Error(`IEC2C not available: ${stderr}`) + } + + const firstLine = stdout.split('\n')[0] // Get the first line of the output + const lineAsArray = firstLine.split(' ') // Split the line by spaces + const version = lineAsArray[lineAsArray.length - 1] // The version is the last element in the array + + return { success: true, data: version } + } + + async getArduinoInstalledCores() { + const coreControlFilePath = join(electronApp.getPath('userData'), 'User', 'Runtime', 'arduino-core-control.json') + const coreControlFileContent = await CompilerModule.readJSONFile(coreControlFilePath) + return coreControlFileContent + } + + async getArduinoInstalledLibraries() { + const libraryControlFilePath = join( + electronApp.getPath('userData'), + 'User', + 'Runtime', + 'arduino-library-control.json', + ) + const libraryControlFileContent = + await CompilerModule.readJSONFile>>(libraryControlFilePath) + + const installedLibraries = libraryControlFileContent.map((lib) => Object.keys(lib)[0]) + + return installedLibraries + } + + // ++ =========================== Defines.h methods ==========================++ + async createMD5Hash(content: string): Promise { + const crypto = await import('node:crypto') + return crypto.createHash('md5').update(content).digest('hex') + } + // ++ ========================= Build Steps ================================= ++ + + // +++++++++++++++++++++++++ Initialization Methods ++++++++++++++++++++++++++++ + async createBasicDirectories( + projectFolderPath: string, + boardTarget: string, + ): Promise> { + // INFO: We don't need to check if the directories already exist, as mkdir with { recursive: true } will handle that. + // INFO: We will create a build directory (if it does not exist), a board-specific directory, and a source directory within the board directory. + let result: MethodsResult = { success: false } + const buildDirectory = join(projectFolderPath, 'build') + const boardDirectory = join(buildDirectory, boardTarget) + const sourceDirectory = join(boardDirectory, 'src') + + // Create the directories recursively. + // INFO: We don't have to create the build directory separately + const results = await Promise.all([ + mkdir(boardDirectory, { recursive: true }), + mkdir(sourceDirectory, { recursive: true }), + ]) + if (results[0] || results[1]) { + result = { success: true, data: [boardDirectory, sourceDirectory] } + } else { + result = { success: true } + } + + return result + } + + // INFO: This method is a placeholder for copying static files. + async copyStaticFiles(compilationPath: string, boardTarget: string): Promise> { + let result: MethodsResult = { success: false } + let filesToCopy: Promise[] = [] + + const staticArduinoFilesPath = join(this.sourceDirectoryPath, 'arduino') + const staticBaremetalFilesPath = join(this.sourceDirectoryPath, 'Baremetal') + const staticMatIECLibraryFilesPath = join(this.sourceDirectoryPath, 'MatIEC', 'lib') + + const sourceTargetFolderPath = join(compilationPath, 'src') + + if (boardTarget !== 'openplc-compiler') { + filesToCopy = [ + cp(staticArduinoFilesPath, sourceTargetFolderPath, { recursive: true }), + cp(staticMatIECLibraryFilesPath, join(sourceTargetFolderPath, 'lib'), { recursive: true }), + cp(staticBaremetalFilesPath, join(compilationPath, 'examples', 'Baremetal'), { recursive: true }), + ] + } else { + // INFO: If the board target is OpenPLC, we copy the MatIEC library files and C/C++ block templates. + const cBlocksHeaderPath = join(this.sourceDirectoryPath, 'arduino', 'c_blocks.h') + const cBlocksCodePath = join(this.sourceDirectoryPath, 'Baremetal', 'c_blocks_code.cpp') + filesToCopy = [ + cp(staticMatIECLibraryFilesPath, join(sourceTargetFolderPath, 'lib'), { recursive: true }), + cp(cBlocksHeaderPath, join(sourceTargetFolderPath, 'c_blocks.h')), + cp(cBlocksCodePath, join(sourceTargetFolderPath, 'c_blocks_code.cpp')), + ] + } + + try { + // Implement the logic to copy static build files. + const results = await Promise.all(filesToCopy) + if (results.every((res) => res === undefined)) { + result = { success: true, data: 'Static build files available' } + } + } catch (error) { + throw new Error(`Error copying static files: ${error as string}`) + } + + return result + } + + // +++++++++++++++++++++++++++ Compilation Methods +++++++++++++++++++++++++++++ + + async handleGenerateXMLfromJSON(sourceTargetFolderPath: string, jsonData: PLCProjectData) { + return new Promise>((resolve, reject) => { + const { data: xmlData } = XmlGenerator(jsonData, 'old-editor') + if (typeof xmlData !== 'string') { + reject(new Error('XML data is not a string')) + return + } + + const xmlCreationResult = CreateXMLFile(sourceTargetFolderPath, xmlData, 'plc') + + if (xmlCreationResult.success) { + resolve({ success: true, data: { xmlPath: sourceTargetFolderPath, xmlContent: xmlData } }) + } else { + reject(new Error('Failed to create XML file')) + } + }) + } + + async handleTranspileXMLtoST( + generatedXMLFilePath: string, + handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, + ) { + return new Promise>((resolve, reject) => { + const executeCommand = this.#executeXml2st(['--generate-st', generatedXMLFilePath]) + + let stderrData = '' + + // INFO: We use the xml2st command to transpile the XML file to ST. + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + + executeCommand.on('close', (code) => { + if (code === 0) { + handleOutputData(`ST file generated at: ${generatedXMLFilePath.replace('plc.xml', 'program.st')}`, 'info') + resolve({ + success: true, + }) + } else { + reject(new Error(`xml2st process exited with code ${code}\n${stderrData}`)) + } + }) + }) + } + + async handleTranspileSTtoC( + generatedSTFilePath: string, + handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, + ) { + // As the iec2c binary generates the C files in the same directory as the binary location, + // we need to set the target directory for the output files accordingly with the generated ST file path. + const targetDirectoryForOutput = join(generatedSTFilePath.replace('program.st', '')) + + let binaryPath = this.iec2cBinaryPath + if (CompilerModule.HOST_PLATFORM === 'win32') { + // INFO: On Windows, we need to add the .exe extension to the binary path. + binaryPath += '.exe' + } + + return new Promise>((resolve, reject) => { + const executeCommand = spawn(binaryPath, ['-f', '-p', '-i', '-l', generatedSTFilePath], { + cwd: targetDirectoryForOutput, + }) + + let stderrData = '' + + // INFO: We use the iec2c command to transpile the ST file to C. + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + + executeCommand.on('close', (code) => { + if (code === 0) { + handleOutputData(`C files generated at: ${targetDirectoryForOutput}`, 'info') + resolve({ + success: true, + }) + } else { + reject(new Error(`iec2c process exited with code ${code}\n${stderrData}`)) + } + }) + }) + } + + async handleGenerateDebugFiles( + sourceTargetFolderPath: string, + handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, + ) { + const generatedSTFilePath = join(sourceTargetFolderPath, 'program.st') // Assuming the XML file is named 'program.st' + const generatedVARIABLESFilePath = join(sourceTargetFolderPath, 'VARIABLES.csv') // Assuming the VARIABLES file is named 'VARIABLES.csv' + + return new Promise>((resolve, reject) => { + const executeCommand = this.#executeXml2st(['--generate-debug', generatedSTFilePath, generatedVARIABLESFilePath]) + + let stderrData = '' + + // INFO: We use the xml2st command to generate debug files. + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + + executeCommand.on('close', (code) => { + if (code === 0) { + handleOutputData(`Debug files generated at: ${sourceTargetFolderPath}`, 'info') + resolve({ + success: true, + }) + } else { + reject(new Error(`xml2st process exited with code ${code}\n${stderrData}`)) + } + }) + }) + } + + async handleGenerateGlueVars( + sourceTargetFolderPath: string, + handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, + ) { + const generatedLocatedVariablesFilePath = join(sourceTargetFolderPath, 'LOCATED_VARIABLES.h') + + return new Promise>((resolve, reject) => { + const executeCommand = this.#executeXml2st(['--generate-gluevars', generatedLocatedVariablesFilePath]) + + let stderrData = '' + + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + + executeCommand.on('close', (code) => { + if (code === 0) { + handleOutputData(`Glue vars generated at: ${sourceTargetFolderPath}`, 'info') + resolve({ + success: true, + }) + } else { + reject(new Error(`xml2st process exited with code ${code}\n${stderrData}`)) + } + }) + }) + } + + // TODO: This method is used to update the index of the Arduino core. + // We should validate if this is necessary and if it works correctly. + async handleCoreUpdateIndex(handleOutputData: HandleOutputDataCallback) { + return new Promise>((resolve, reject) => { + let binaryPath = this.arduinoCliBinaryPath + const [flag, configFilePath] = this.arduinoCliBaseParameters + + if (CompilerModule.HOST_PLATFORM === 'win32') { + // INFO: On Windows, we need to add the .exe extension to the binary path. + binaryPath += '.exe' + } + const executeCommand = spawn(binaryPath, ['core', 'update-index', flag, configFilePath]) + + let stderrData = '' + + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + executeCommand.on('close', (code) => { + if (code === 0) { + resolve({ + success: true, + }) + } else { + reject(new Error(`Arduino CLI process exited with code ${code}\n${stderrData}`)) + } + }) + }) + } + + async handleCoreInstallation( + boardCore: string | null, + handleOutputData: (chunk: Buffer | string, logLevel?: 'info' | 'error') => void, + ) { + if (boardCore === null) return + + const isCoreInstalled = Object.keys(await this.getArduinoInstalledCores()).some((core) => core === boardCore) + if (isCoreInstalled) { + handleOutputData(`Core ${boardCore} is already installed.`, 'info') + return + } + + let binaryPath = this.arduinoCliBinaryPath + + if (CompilerModule.HOST_PLATFORM === 'win32') { + // INFO: On Windows, we need to add the .exe extension to the binary path. + binaryPath += '.exe' + } + return new Promise>((resolve, reject) => { + const executeCommand = spawn(binaryPath, ['core', 'install', boardCore, ...this.arduinoCliBaseParameters]) + + let stderrData = '' + + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + executeCommand.on('close', (code) => { + if (code === 0) { + resolve({ + success: true, + }) + } else { + reject(new Error(`Arduino CLI process exited with code ${code}\n${stderrData}`)) + } + }) + }) + } + + // Handle library installation + // In the future, this method will be responsible for installing any missing libraries. + // This should receive a list of libraries to install. + async handleLibraryInstallation(handleOutputData: HandleOutputDataCallback) { + // 1. Check what are the required libraries for the project - This will be the global libraries and the extra libraries that comes from the hals.json file. + // This will be filled later, for now is just a placeholder. + const extraLibraries: string[] = ['P1AM'] // We provide this value just for testing purposes. + const requiredLibraries = Array.from(new Set([...CompilerModule.GLOBAL_LIBRARIES, ...extraLibraries])) + + // 2. Check if all required libraries are already installed + const installedLibraries = await this.getArduinoInstalledLibraries() + const missingLibraries = requiredLibraries.filter((lib) => !installedLibraries.includes(lib)) + + if (missingLibraries.length === 0) { + handleOutputData(`All required libraries are already installed.`, 'info') + return + } + + let binaryPath = this.arduinoCliBinaryPath + if (CompilerModule.HOST_PLATFORM === 'win32') { + // INFO: On Windows, we need to add the .exe extension to the binary path. + binaryPath += '.exe' + } + + // 3. If not installed, run the installation command + return new Promise>((resolve, reject) => { + const executeCommand = spawn(binaryPath, [ + 'lib', + 'install', + ...missingLibraries, + ...this.arduinoCliBaseParameters, + ]) + + let stderrData = '' + + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + executeCommand.on('close', (code) => { + if (code === 0) { + handleOutputData(`All libraries installed!`, 'info') + resolve({ + success: true, + }) + } else { + reject(new Error(`Arduino CLI process exited with code ${code}\n${stderrData}`)) + } + }) + }) + // 4. Update the library index + } + + // TODO: This method is used to update the index of the Arduino libraries. + // We should validate if this is necessary and if it works correctly. + async handleLibraryUpdateIndex(handleOutputData: HandleOutputDataCallback) { + return new Promise>((resolve, reject) => { + let binaryPath = this.arduinoCliBinaryPath + const [flag, configFilePath] = this.arduinoCliBaseParameters + + if (CompilerModule.HOST_PLATFORM === 'win32') { + // INFO: On Windows, we need to add the .exe extension to the binary path. + binaryPath += '.exe' + } + const executeCommand = spawn(binaryPath, ['lib', 'update-index', flag, configFilePath]) + + let stderrData = '' + + executeCommand.stdout?.on('data', (data: Buffer) => { + handleOutputData(data) + }) + executeCommand.stderr?.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + executeCommand.on('close', (code) => { + if (code === 0) { + resolve({ + success: true, + }) + } else { + reject(new Error(`Arduino CLI process exited with code ${code}\n${stderrData}`)) + } + }) + }) + } + + async handleGenerateDefinitionsFile({ + projectPath, + buildMD5Hash, + boardTarget, + boardRuntime, + _handleOutputData, + }: { + projectPath: string + boardTarget: string + buildMD5Hash: string + boardRuntime: string + _handleOutputData: HandleOutputDataCallback + }) { + let DEFINES_CONTENT: string = '' + + // === Directories and files paths === + const devicesDirectoryPath = join(projectPath, 'devices') + const devicesConfigurationFilePath = join(devicesDirectoryPath, 'configuration.json') + const devicesPinMappingFilePath = join(devicesDirectoryPath, 'pin-mapping.json') + + const buildTargetDirectoryPath = join(projectPath, 'build', boardTarget) + + const stProgramFilePath = join(buildTargetDirectoryPath, 'src', 'program.st') + + const definitionsFilePath = join(buildTargetDirectoryPath, 'examples', 'Baremetal', 'defines.h') + + // === Files contents that we need === + const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) + const { + communicationConfiguration: { modbusRTU, modbusTCP, communicationPreferences }, + } = await CompilerModule.readJSONFile(devicesConfigurationFilePath) + const devicePinMapping = await CompilerModule.readJSONFile(devicesPinMappingFilePath) + const stProgramFileContent = await readFile(stProgramFilePath, 'utf-8') + + // We extract the board entry from the hals file content to validate if it has the define property. + const boardEntry = halsFileContent[boardTarget] + + // ===== Defines.h content generation ===== + + // 1. We need to verify if the board entry in the hals.json file has the define property. + if (boardEntry && boardEntry.define) { + // 1.2. If it has the defines property, we will write a header and iterate over the defines to create the content for the defines.h file. + DEFINES_CONTENT = '// Board defines\n' + if (Array.isArray(boardEntry.define)) { + // 1.3. If the defines property is an array, we will iterate over it and add each define to the content. + boardEntry.define.forEach((define) => { + DEFINES_CONTENT += `#define ${define}\n` + }) + } else if (typeof boardEntry.define === 'string') { + // 1.4. If the defines property is a string, we will add it directly to the content. + DEFINES_CONTENT += `#define ${boardEntry.define}\n` + } + } + + // 2. If the board entry does not have the define property, we will just write a double line break to the file. + DEFINES_CONTENT += '\n\n' + + // 3. Now we write the information for the defines.h file based on the device configuration and other preferences. + + /** + * TODOS + * 3. In the device configuration we need to verify why the values that should be null are being set to empty strings. + * 4. We need to ensure that the pins are correctly sorted according to their address. + */ + + // 3.1. Program MD5 + DEFINES_CONTENT += '//Program MD5\n' + DEFINES_CONTENT += `#define PROGRAM_MD5 "${buildMD5Hash}"` + DEFINES_CONTENT += `\n\n` + + // 3.2. Device Configuration + DEFINES_CONTENT += '//Comms Configuration\n' + if (boardRuntime === 'simulator') { + // Simulator forces fixed Modbus RTU settings over emulated USART0. + // On ATmega2560, Serial = USART0. avr8js bridges usart0. + DEFINES_CONTENT += '#define SIMULATOR_MODE\n' + DEFINES_CONTENT += '#define MBSERIAL_IFACE Serial\n' + DEFINES_CONTENT += '#define MBSERIAL_BAUD 115200\n' + DEFINES_CONTENT += '#define MBSERIAL_SLAVE 1\n' + } else { + DEFINES_CONTENT += `#define MBSERIAL_IFACE ${modbusRTU.rtuInterface}\n` + DEFINES_CONTENT += `#define MBSERIAL_BAUD ${modbusRTU.rtuBaudRate}\n` + if (modbusRTU.rtuSlaveId !== null) DEFINES_CONTENT += `#define MBSERIAL_SLAVE ${modbusRTU.rtuSlaveId}\n` + if (modbusRTU.rtuRS485ENPin !== null) DEFINES_CONTENT += `#define MBSERIAL_TXPIN ${modbusRTU.rtuRS485ENPin}\n` + } + if (modbusTCP.tcpMacAddress !== null) + DEFINES_CONTENT += `#define MBTCP_MAC ${FormatMacAddress(modbusTCP.tcpMacAddress)}\n` + // OBS: This is giving us an empty string and this is being printed as a space + if (modbusTCP.tcpStaticHostConfiguration.ipAddress !== null) + DEFINES_CONTENT += `#define MBTCP_IP ${modbusTCP.tcpStaticHostConfiguration.ipAddress.replaceAll('.', ',')}\n` + if (modbusTCP.tcpStaticHostConfiguration.dns !== null) + DEFINES_CONTENT += `#define MBTCP_DNS ${modbusTCP.tcpStaticHostConfiguration.dns.replaceAll('.', ',')}\n` + if (modbusTCP.tcpStaticHostConfiguration.gateway !== null) + DEFINES_CONTENT += `#define MBTCP_GATEWAY ${modbusTCP.tcpStaticHostConfiguration.gateway.replaceAll('.', ',')}\n` + if (modbusTCP.tcpStaticHostConfiguration.subnet !== null) + DEFINES_CONTENT += `#define MBTCP_SUBNET ${modbusTCP.tcpStaticHostConfiguration.subnet.replaceAll('.', ',')}\n` + + if (communicationPreferences.enabledRTU || boardRuntime === 'simulator') { + DEFINES_CONTENT += '#define MBSERIAL\n' + DEFINES_CONTENT += '#define MODBUS_ENABLED\n' + } + + if (communicationPreferences.enabledTCP) { + DEFINES_CONTENT += '#define MBTCP\n' + DEFINES_CONTENT += '#define MODBUS_ENABLED\n' + if (modbusTCP.tcpInterface === 'Wi-Fi') { + if (modbusTCP.tcpWifiSSID !== null) { + DEFINES_CONTENT += `#define MBTCP_SSID "${modbusTCP.tcpWifiSSID}"\n` + } + if (modbusTCP.tcpWifiPassword !== null) { + DEFINES_CONTENT += `#define MBTCP_PWD "${modbusTCP.tcpWifiPassword}"\n` + } + DEFINES_CONTENT += '#define MBTCP_WIFI\n' + } else { + DEFINES_CONTENT += '#define MBTCP_ETHERNET\n' + } + } + + DEFINES_CONTENT += `\n\n` + + // INFO: If null, only the define value + // 3.3. IO Config defines + DEFINES_CONTENT += '//IO Config\n' + // INFO: This approach assumes that the pins are sorted. + const digitalInputPins = devicePinMapping.filter((pin) => pin.pinType === 'digitalInput') + const analogInputPins = devicePinMapping.filter((pin) => pin.pinType === 'analogInput') + const digitalOutputPins = devicePinMapping.filter((pin) => pin.pinType === 'digitalOutput') + const analogOutputPins = devicePinMapping.filter((pin) => pin.pinType === 'analogOutput') + + DEFINES_CONTENT += `#define PINMASK_DIN ${digitalInputPins.map(({ pin }) => pin).join(', ')}\n` + DEFINES_CONTENT += `#define PINMASK_AIN ${analogInputPins.map(({ pin }) => pin).join(', ')}\n` + DEFINES_CONTENT += `#define PINMASK_DOUT ${digitalOutputPins.map(({ pin }) => pin).join(', ')}\n` + DEFINES_CONTENT += `#define PINMASK_AOUT ${analogOutputPins.map(({ pin }) => pin).join(', ')}\n` + + DEFINES_CONTENT += `#define NUM_DISCRETE_INPUT ${digitalInputPins.length}\n` + DEFINES_CONTENT += `#define NUM_ANALOG_INPUT ${analogInputPins.length}\n` + DEFINES_CONTENT += `#define NUM_DISCRETE_OUTPUT ${digitalOutputPins.length}\n` + DEFINES_CONTENT += `#define NUM_ANALOG_OUTPUT ${analogOutputPins.length}\n` + DEFINES_CONTENT += `\n\n` + + // 3.4. Arduino libraries defines + DEFINES_CONTENT += '//Arduino libraries\n' + if ( + stProgramFileContent.includes('DS18B20;') || + stProgramFileContent.includes('DS18B20_2_OUT;') || + stProgramFileContent.includes('DS18B20_3_OUT;') || + stProgramFileContent.includes('DS18B20_4_OUT;') || + stProgramFileContent.includes('DS18B20_5_OUT;') + ) { + DEFINES_CONTENT += '#define USE_DS18B20_BLOCK\n' + } + + if (stProgramFileContent.includes('P1AM_INIT;')) DEFINES_CONTENT += '#define USE_P1AM_BLOCKS\n' + + if (stProgramFileContent.includes('CLOUD_BEGIN;')) DEFINES_CONTENT += '#define USE_CLOUD_BLOCKS\n' + + if (stProgramFileContent.includes('MQTT_CONNECT;') || stProgramFileContent.includes('MQTT_CONNECT_AUTH;')) + DEFINES_CONTENT += '#define USE_MQTT_BLOCKS\n' + + if ( + stProgramFileContent.includes('ARDUINOCAN_CONF;') || + stProgramFileContent.includes('ARDUINOCAN_WRITE;') || + stProgramFileContent.includes('ARDUINOCAN_WRITE_WORD;') || + stProgramFileContent.includes('ARDUINOCAN_READ;') + ) { + DEFINES_CONTENT += '#define USE_ARDUINOCAN_BLOCK\n' + } + + if ( + stProgramFileContent.includes('STM32CAN_CONF;') || + stProgramFileContent.includes('STM32CAN_WRITE;') || + stProgramFileContent.includes('STM32CAN_READ;') + ) { + DEFINES_CONTENT += '#define USE_STM32CAN_BLOCK\n' + } + + if ( + stProgramFileContent.includes('SM_8RELAY;') || + stProgramFileContent.includes('SM_16RELAY;') || + stProgramFileContent.includes('SM_8DIN;') || + stProgramFileContent.includes('SM_16DIN;') || + stProgramFileContent.includes('SM_4REL4IN;') || + stProgramFileContent.includes('SM_INDUSTRIAL;') || + stProgramFileContent.includes('SM_RTD;') || + stProgramFileContent.includes('SM_BAS;') || + stProgramFileContent.includes('SM_HOME;') || + stProgramFileContent.includes('SM_8MOSFET;') + ) { + DEFINES_CONTENT += '#define USE_SM_BLOCKS\n' + } + + // 4. Finally, we attempt to write the content to the defines.h file. + try { + await writeFile(definitionsFilePath, DEFINES_CONTENT, { encoding: 'utf8' }) + _handleOutputData(`Defines file created at: ${definitionsFilePath}`, 'info') + } catch (_error) { + _handleOutputData('Error writing defines.h file', 'error') + } + } + + async handlePatchGeneratedFiles(compilationPath: string, handleOutputData: HandleOutputDataCallback) { + const pousCFilePath = join(compilationPath, 'src', 'POUS.c') + const res0FilePath = join(compilationPath, 'src', 'Res0.c') + const config0FilePath = join(compilationPath, 'src', 'Config0.c') + + const pousCContent = await readFile(pousCFilePath, { encoding: 'utf8' }) + const patchedPousCContent = `#include "POUS.h"\n#include "Config0.h"\n\n${pousCContent}` + await writeFile(pousCFilePath, patchedPousCContent, { encoding: 'utf8' }) + + const res0FileContent = await readFile(res0FilePath, { encoding: 'utf8' }) + + const patchedRes0FileContent = res0FileContent.replaceAll('#include "POUS.c"', '#include "POUS.h"\n') + + await writeFile(res0FilePath, patchedRes0FileContent, { encoding: 'utf8' }) + handleOutputData('Required files patched', 'info') + + // Unity build: Rename .c files to .inc so Arduino build system doesn't compile them separately. + // These files are #included by glueVars.c as a single compilation unit to avoid + // duplicate static function definitions that cause binary size bloat. + const pousIncFilePath = join(compilationPath, 'src', 'POUS.inc') + const res0IncFilePath = join(compilationPath, 'src', 'Res0.inc') + const config0IncFilePath = join(compilationPath, 'src', 'Config0.inc') + + await fs.rename(pousCFilePath, pousIncFilePath) + await fs.rename(res0FilePath, res0IncFilePath) + await fs.rename(config0FilePath, config0IncFilePath) + handleOutputData('Files renamed to .inc for unity build', 'info') + } + + async handleGenerateArduinoCppFile(projectPath: string, boardTarget: string) { + let result: MethodsResult = { success: false } + + const halsFileContent = await CompilerModule.readJSONFile(this.halsFilePath) + + const boardSourceFile = halsFileContent[boardTarget]['source'] + + const boardSourceFilePath = join(this.sourceDirectoryPath, 'hal', boardSourceFile) + const arduinoCppFilePath = join(projectPath, 'build', boardTarget, 'src', 'arduino.cpp') + + try { + await cp(boardSourceFilePath, arduinoCppFilePath, { recursive: true }) + result = { success: true, data: arduinoCppFilePath } + } catch (error) { + throw new Error(`Error copying Arduino source file: ${(error as Error).message}`) + } + return result + } + + async handleGenerateCBlocksHeader( + projectData: PLCProjectData & { originalCppPous?: CppPouDataCode[] }, + sourceTargetFolderPath: string, + handleOutputData: HandleOutputDataCallback, + ) { + const originalCppPous = projectData.originalCppPous || [] + + if (originalCppPous.length === 0) { + handleOutputData('No C/C++ blocks found, skipping c_blocks.h generation', 'info') + return + } + + const cppPous = originalCppPous.map((pou) => ({ + name: pou.name, + variables: pou.variables, + })) as CppPouDataHeader[] + + const headerContent: string = generateCBlocksHeader(cppPous) + const headerFilePath = join(sourceTargetFolderPath, 'c_blocks.h') + + try { + await writeFile(headerFilePath, headerContent, { encoding: 'utf8' }) + handleOutputData(`C blocks header file populated at: ${headerFilePath}`, 'info') + } catch (error) { + throw new Error(`Error writing c_blocks.h file: ${(error as Error).message}`) + } + } + + async handleGenerateCBlocksCode( + projectData: PLCProjectData & { originalCppPous?: CppPouDataCode[] }, + compilationPath: string, + boardRuntime: string, + handleOutputData: HandleOutputDataCallback, + ) { + const originalCppPous = projectData.originalCppPous || [] + + if (originalCppPous.length === 0) { + handleOutputData('No C/C++ blocks found, skipping c_blocks_code.cpp generation', 'info') + return + } + + const cppPous = originalCppPous + const codeContent = generateCBlocksCode(cppPous) + + const codeFilePath = + boardRuntime === 'openplc-compiler' + ? join(compilationPath, 'src', 'c_blocks_code.cpp') + : join(compilationPath, 'examples', 'Baremetal', 'c_blocks_code.cpp') + + try { + const existingContent = await readFile(codeFilePath, { encoding: 'utf8' }) + const updatedContent = existingContent + '\n' + codeContent + await writeFile(codeFilePath, updatedContent, { encoding: 'utf8' }) + handleOutputData(`C blocks code file populated at: ${codeFilePath}`, 'info') + } catch (error) { + throw new Error(`Error writing c_blocks_code.cpp file: ${(error as Error).message}`) + } + } + + async handleCompileArduinoProgram({ + boardHalsContent, + compilationPath, + handleOutputData, + }: CompileArduinoProgramArgs) { + const baremetalPath = join(compilationPath, 'examples', 'Baremetal') + + let buildProjectFlags = ['compile', '-v'] + + if (boardHalsContent['c_flags']) { + buildProjectFlags = [ + ...buildProjectFlags, + '--build-property', + `compiler.c.extra_flags=${boardHalsContent['c_flags'].map((f) => f).join(' ')}`, + ] + } + + if (boardHalsContent['cxx_flags']) { + buildProjectFlags = [ + ...buildProjectFlags, + '--build-property', + `compiler.cpp.extra_flags=${boardHalsContent['cxx_flags'].map((f) => f).join(' ')}`, + ] + } + + if (boardHalsContent['ld_flags']) { + buildProjectFlags = [ + ...buildProjectFlags, + '--build-property', + `compiler.c.elf.extra_flags=${boardHalsContent['ld_flags'].map((f: string) => f).join(' ')}`, + ] + } + + buildProjectFlags = [ + ...buildProjectFlags, + '--library', + `${join(compilationPath, 'src')}`, // Basic libraries + '--library', + `${join(compilationPath, 'src', 'lib')}`, // Arduino libraries + '--export-binaries', // Export binaries + '-b', + boardHalsContent['platform'], // Board target + join(baremetalPath, 'Baremetal.ino'), // Arduino .ino file + ...this.arduinoCliBaseParameters, // Base parameters + ] + + return new Promise>((resolve, reject) => { + const child = this.#executeArduinoCliCommand(buildProjectFlags) + let stderrData = '' + child.stdout.on('data', (data: Buffer) => { + handleOutputData(data) + }) + child.stderr.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + child.on('close', (code) => { + if (code === 0) { + resolve({ success: true }) + } else { + reject(new Error(`Compilation failed with code ${code}\n${stderrData}`)) + } + }) + }) + } + + async handleUploadProgram({ + projectPath, + arduinoPlatform, + compilationPath, + handleOutputData, + }: { + projectPath: string + arduinoPlatform: string + compilationPath: string + handleOutputData: HandleOutputDataCallback + }) { + const devicesDirectoryPath = join(projectPath, 'devices') + const devicesConfigurationFilePath = join(devicesDirectoryPath, 'configuration.json') + const { communicationPort: port } = + await CompilerModule.readJSONFile(devicesConfigurationFilePath) + const baremetalPath = join(compilationPath, 'examples', 'Baremetal') + + if (!port) { + handleOutputData('No communication port specified', 'error') + return + } + + return new Promise>((resolve, reject) => { + const child = this.#executeArduinoCliCommand([ + 'upload', + '--port', + port, + '--fqbn', + arduinoPlatform, + baremetalPath, + ...this.arduinoCliBaseParameters, + ]) + + let stderrData = '' + + child.stdout.on('data', (data: Buffer) => { + handleOutputData(data) + }) + child.stderr.on('data', (data: Buffer) => { + stderrData += data.toString() + }) + child.on('close', (code) => { + if (code === 0) { + resolve({ + success: true, + }) + } else { + reject(new Error(`Upload failed with code ${code}\n${stderrData}`)) + } + }) + }) + } + + // !! Deprecated: This method is a outdated implementation and should be removed. + async createXmlFile( + pathToUserProject: string, + dataToCreateXml: PLCProjectData, + parseTo: 'old-editor' | 'codesys', + ): Promise<{ success: boolean; message: string }> { + const { filePath } = await dialog.showSaveDialog({ + title: 'Export Project', + defaultPath: join(pathToUserProject, 'plc.xml'), + buttonLabel: 'Save', + filters: [{ name: 'XML Files', extensions: ['xml'] }], + }) + + if (!filePath) { + return { success: false, message: 'User canceled the save dialog' } + } + + const { data: projectDataAsString, message } = XmlGenerator(dataToCreateXml, parseTo) as { + data: string | undefined + message: string + } + if (!projectDataAsString) { + return { success: false, message: message } + } + + const result = CreateXMLFile(filePath, projectDataAsString, 'plc') + try { + await writeFile(filePath, projectDataAsString) + console.log('File written to:', filePath) + } catch (err) { + console.error('Error writing file:', err) + } + + return { + success: result.success, + message: result.success ? ` XML file created at ${filePath}` : 'Failed to create XML file', + } + } + + // ++ ========================= Compiler builder ============================ ++ + + async compressSourceFolder(sourceFolderPath: string): Promise { + const zip = new JSZip() + + async function addFilesToZip(currentPath: string, zipFolder: JSZip, relativePath: string = ''): Promise { + const entries = await fs.readdir(currentPath, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name) + const zipPath = relativePath ? path.join(relativePath, entry.name) : entry.name + + if (entry.isDirectory()) { + await addFilesToZip(fullPath, zipFolder, zipPath) + } else { + const fileContent = await fs.readFile(fullPath) + zipFolder.file(zipPath, fileContent) + } + } + } + + await addFilesToZip(sourceFolderPath, zip) + + const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }) + return zipBuffer + } + + async cleanConfFolder(sourceTargetFolderPath: string, handleOutputData: HandleOutputDataCallback): Promise { + const confFolderPath = join(sourceTargetFolderPath, 'conf') + + try { + await fs.access(confFolderPath) + await fs.rm(confFolderPath, { recursive: true }) + handleOutputData('Cleaned conf folder from previous compilation', 'info') + } catch { + handleOutputData('No conf folder to clean', 'info') + } + } + + async handleGenerateModbusSlaveConfig( + sourceTargetFolderPath: string, + projectData: PLCProjectData, + handleOutputData: HandleOutputDataCallback, + ): Promise { + const modbusSlaveConfig: string | null = generateModbusSlaveConfig(projectData.servers) + + if (modbusSlaveConfig) { + const confFolderPath = join(sourceTargetFolderPath, 'conf') + await mkdir(confFolderPath, { recursive: true }) + const configFilePath = join(confFolderPath, 'modbus_slave.json') + await writeFile(configFilePath, modbusSlaveConfig, 'utf-8') + handleOutputData('Generated conf/modbus_slave.json', 'info') + } else { + handleOutputData('No Modbus TCP server configured, skipping modbus_slave.json generation', 'info') + } + } + + async handleGenerateModbusMasterConfig( + sourceTargetFolderPath: string, + projectData: PLCProjectData, + handleOutputData: HandleOutputDataCallback, + ): Promise { + const modbusMasterConfig: string | null = generateModbusMasterConfig(projectData.remoteDevices) + + if (modbusMasterConfig) { + const confFolderPath = join(sourceTargetFolderPath, 'conf') + await mkdir(confFolderPath, { recursive: true }) + const configFilePath = join(confFolderPath, 'modbus_master.json') + await writeFile(configFilePath, modbusMasterConfig, 'utf-8') + handleOutputData('Generated conf/modbus_master.json', 'info') + } else { + handleOutputData('No Modbus TCP remote devices configured, skipping modbus_master.json generation', 'info') + } + } + + async handleGenerateS7CommConfig( + sourceTargetFolderPath: string, + projectData: PLCProjectData, + handleOutputData: HandleOutputDataCallback, + ): Promise { + try { + const s7commConfig: string | null = generateS7CommConfig(projectData.servers) + + if (s7commConfig) { + const confFolderPath = join(sourceTargetFolderPath, 'conf') + await mkdir(confFolderPath, { recursive: true }) + const configFilePath = join(confFolderPath, 's7comm.json') + await writeFile(configFilePath, s7commConfig, 'utf-8') + handleOutputData('Generated conf/s7comm.json', 'info') + } else { + handleOutputData('No S7Comm server configured, skipping s7comm.json generation', 'info') + } + } catch (error) { + const errorMessage = getErrorMessage(error) + handleOutputData(`Failed to generate S7Comm config: ${errorMessage}`, 'error') + throw error + } + } + + /** + * Generate OPC-UA server configuration for Runtime v4. + * Reads debug.c to resolve variable indices and generates opcua.json. + */ + async handleGenerateOpcUaConfig( + sourceTargetFolderPath: string, + projectData: PLCProjectData, + handleOutputData: HandleOutputDataCallback, + ): Promise { + try { + // Check if there's an enabled OPC-UA server + const opcuaServer = projectData.servers?.find( + (s) => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled, + ) + + if (!opcuaServer || !opcuaServer.opcuaServerConfig) { + handleOutputData('No OPC-UA server configured, skipping opcua.json generation', 'info') + return + } + + // Read the debug.c file generated by xml2st + const debugCPath = join(sourceTargetFolderPath, 'debug.c') + let debugContent: string + + try { + debugContent = await readFile(debugCPath, 'utf-8') + } catch { + handleOutputData('Warning: Could not read debug.c file. OPC-UA variable indices may not be resolved.', 'error') + debugContent = '' + } + + // Get instances from Resources configuration for index resolution + const instances = projectData.configuration.resource.instances.map((inst) => ({ + name: inst.name, + task: inst.task, + program: inst.program, + })) + + // Generate the OPC-UA configuration + const opcuaJson: string | null = generateOpcUaConfig(projectData.servers, debugContent, instances) + + if (opcuaJson) { + // Ensure conf directory exists + const confFolderPath = join(sourceTargetFolderPath, 'conf') + await mkdir(confFolderPath, { recursive: true }) + + // Write the configuration file + const configFilePath = join(confFolderPath, 'opcua.json') + await writeFile(configFilePath, opcuaJson, 'utf-8') + handleOutputData('Generated conf/opcua.json', 'info') + + // Log the number of configured nodes + const nodeCount = opcuaServer.opcuaServerConfig.addressSpace.nodes.length + handleOutputData(`OPC-UA Address Space: ${nodeCount} node(s) configured`, 'info') + } else { + handleOutputData('OPC-UA server enabled but no configuration generated', 'info') + } + } catch (error) { + if (error instanceof OpcUaConfigError) { + handleOutputData(`OPC-UA Configuration Error:\n${error.message}`, 'error') + } else { + const errorMessage = getErrorMessage(error) + handleOutputData(`Failed to generate OPC-UA config: ${errorMessage}`, 'error') + } + throw error + } + } + + async embedCBlocksInProgramSt( + sourceTargetFolderPath: string, + handleOutputData: HandleOutputDataCallback, + ): Promise { + const programStPath = join(sourceTargetFolderPath, 'program.st') + const cBlocksHeaderPath = join(sourceTargetFolderPath, 'c_blocks.h') + const cBlocksCodePath = join(sourceTargetFolderPath, 'c_blocks_code.cpp') + + try { + let programStContent = await readFile(programStPath, 'utf8') + + try { + await fs.access(cBlocksHeaderPath) + const headerContent = await readFile(cBlocksHeaderPath, 'utf8') + const headerLines = headerContent.split('\n') + const embeddedHeader = headerLines.map((line) => `(*FILE:c_blocks.h ${line} *)`).join('\n') + programStContent += '\n' + embeddedHeader + + handleOutputData('Embedded c_blocks.h into program.st for Runtime v3', 'info') + } catch { + handleOutputData('c_blocks.h not found, skipping embedding', 'info') + } + + try { + await fs.access(cBlocksCodePath) + const codeContent = await readFile(cBlocksCodePath, 'utf8') + const codeLines = codeContent.split('\n') + const embeddedCode = codeLines.map((line) => `(*FILE:c_blocks_code.cpp ${line} *)`).join('\n') + programStContent += '\n' + embeddedCode + + handleOutputData('Embedded c_blocks_code.cpp into program.st for Runtime v3', 'info') + } catch { + handleOutputData('c_blocks_code.cpp not found, skipping embedding', 'info') + } + + await writeFile(programStPath, programStContent, 'utf8') + } catch (error) { + throw new Error(`Error embedding C blocks in program.st: ${(error as Error).message}`) + } + } + + /** + * This will be the main entry point for the compiler module. + * It will handle all the compilation process, will orchestrate the various steps involved in compiling a program. + */ + // Work in progress - we should specify the arguments and the return type correctly. + async compileProgram( + args: Array, + _mainProcessPort: MessagePortMain, + mainProcessBridge: { + makeRuntimeApiRequest: ( + ipAddress: string, + jwtToken: string, + endpoint: string, + responseParser?: (data: string) => T, + ) => Promise<{ success: true; data?: T } | { success: false; error: string }> + }, + ): Promise { + // Start the main process port to communicate with the renderer process. + // INFO: This is necessary to send messages back to the renderer process. + _mainProcessPort.start() + + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Starting compilation process...' }) + // INFO: We assume the first argument is the project path, + // INFO: the second argument is the board target, and the third argument is the project data. + const [projectPath, boardTarget, boardCore, compileOnly, projectData, runtimeIpAddress, runtimeJwtToken] = args as [ + string, + string, + string | null, + boolean, + PLCProjectData, + string | null, + string | null, + ] + + const boardRuntime = await this.#getBoardRuntime(boardTarget) // Get the board runtime from the hals.json file + + const halsContent = await CompilerModule.readJSONFile(this.halsFilePath) + + const normalizedProjectPath = projectPath.replace('project.json', '') + + const compilationPath = join(normalizedProjectPath, 'build', boardTarget) // Assuming the build folder is named 'build' + + const sourceTargetFolderPath = join(compilationPath, 'src') // Assuming the source folder is named 'src' + + let buildMD5Hash: string | null = null + + // --- Print basic information --- + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Compiling program for project: ${projectPath} and board target: ${boardTarget}`, + }) + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: 'Host Hardware Info:', + }) + _mainProcessPort.postMessage({ + message: this.getHostHardwareInfo(), + }) + + // --- Check for unsupported features on non-v4 targets --- + const isRuntimeV4 = boardTarget === 'OpenPLC Runtime v4' + const hasServers = projectData.servers && projectData.servers.length > 0 + const hasRemoteDevices = projectData.remoteDevices && projectData.remoteDevices.length > 0 + + if (!isRuntimeV4 && hasServers) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: `Warning: Your project contains Modbus Server configurations, but the selected target (${boardTarget}) does not support this feature. Modbus Server is only supported on OpenPLC Runtime v4. The server configurations will be ignored during compilation.`, + }) + } + + if (!isRuntimeV4 && hasRemoteDevices) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: `Warning: Your project contains Remote IO configurations, but the selected target (${boardTarget}) does not support this feature. Remote IO is only supported on OpenPLC Runtime v4. The remote device configurations will be ignored during compilation.`, + }) + } + + // --- Check tools availability --- + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Checking tools availability...' }) + + try { + const [arduinoCliCheckResult, iec2cCheckResult] = await Promise.all([ + this.checkArduinoCliAvailability(), + this.checkIec2cAvailability(), + ]) + _mainProcessPort.postMessage({ + message: `Arduino CLI available at version ${arduinoCliCheckResult.data}\nIEC2C available at version ${iec2cCheckResult.data}`, + }) + } catch (_error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + message: `${_error}\nStopping compilation process.`, + }) + _mainProcessPort.close() + return + } + + // Step 1: Create basic directories + try { + await this.createBasicDirectories(normalizedProjectPath, boardTarget) + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Directories for compilation source files created.', + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + message: `${error}\nStopping compilation process.`, + }) + _mainProcessPort.close() + return + } + + // Step 2: Generate XML from JSON + let generateXMLResult: MethodsResult<{ xmlPath: string; xmlContent: string }> = { success: false } + try { + generateXMLResult = await this.handleGenerateXMLfromJSON(sourceTargetFolderPath, projectData) + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Generated XML from JSON at: ${generateXMLResult.data?.xmlPath as string}`, + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error generating XML from JSON: ${error as string}\nStopping compilation process.`, + }) + _mainProcessPort.close() + return + } + + // Step 3: Transpile XML to ST + const generatedXMLFilePath = join(sourceTargetFolderPath, 'plc.xml') // Assuming the XML file is named 'plc.xml' + try { + await this.handleTranspileXMLtoST(generatedXMLFilePath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error transpiling XML to ST: ${error as string}\nStopping compilation process.`, + }) + _mainProcessPort.close() + return + } + + // -- Copy static files -- + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Copying static files...' }) + try { + await this.copyStaticFiles(compilationPath, boardRuntime) + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Static files copied successfully.' }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error copying static files: ${error as string}\nStopping compilation process.`, + }) + _mainProcessPort.close() + return + } + + // Step 4: Generate C code from ST + const generatedSTFilePath = join(sourceTargetFolderPath, 'program.st') // Assuming the ST file is named 'program.st' + try { + await this.handleTranspileSTtoC(generatedSTFilePath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + + // Step 5: Generate debug files + try { + await this.handleGenerateDebugFiles(sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + + try { + const fs = await import('fs/promises') + const programStPath = join(sourceTargetFolderPath, 'program.st') + const programStContent = await fs.readFile(programStPath, 'utf-8') + const md5Pattern = /\(\*DBG:char md5\[\] = "([a-fA-F0-9]{32})";?\*\)/ + const match = programStContent.match(md5Pattern) + + if (match && match[1]) { + buildMD5Hash = match[1] + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Extracted MD5 hash from program.st: ${buildMD5Hash}`, + }) + } else { + _mainProcessPort.postMessage({ + logLevel: 'warn', + message: 'Could not extract MD5 from program.st, continuing without MD5', + }) + buildMD5Hash = null + } + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error extracting MD5 from program.st: ${error as string}`, + }) + buildMD5Hash = null + } + + // Step 6: Generate glue vars + try { + await this.handleGenerateGlueVars(sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + + // Step 7: Generate C/C++ blocks header file + try { + await this.handleGenerateCBlocksHeader(projectData, sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + + // Step 8: Generate C/C++ blocks code file + try { + await this.handleGenerateCBlocksCode(projectData, compilationPath, boardRuntime, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + + // Step 9: Embed C/C++ blocks in program.st for Runtime v3 + if (boardRuntime === 'openplc-compiler' && boardTarget === 'OpenPLC Runtime v3') { + try { + await this.embedCBlocksInProgramSt(sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + } + + // -- Verify if the runtime target is Arduino or OpenPLC -- + // INFO: If the runtime target is Arduino, we will continue the compilation process. + // INFO: If the runtime target is OpenPLC we will finish the process here. + if (boardRuntime === 'openplc-compiler') { + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'OpenPLC runtime detected.', + }) + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Source files generated successfully at: ' + sourceTargetFolderPath, + }) + + if (compileOnly) { + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Compile only mode - skipping upload to runtime.', + }) + _mainProcessPort.postMessage({ + message: + '-------------------------------------------------------------------------------------------------------------\n', + }) + _mainProcessPort.close() + return + } + + if (!runtimeIpAddress || !runtimeJwtToken) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: 'Runtime not configured or not logged in. Skipping upload to runtime.', + }) + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'To upload the program, configure the runtime IP address and login in the device configuration.', + }) + _mainProcessPort.postMessage({ + message: + '-------------------------------------------------------------------------------------------------------------\n', + }) + _mainProcessPort.close() + return + } + + try { + const isRuntimeV3 = boardTarget === 'OpenPLC Runtime v3' + + let fileBuffer: Buffer + let filename: string + let contentType: string + + if (isRuntimeV3) { + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Preparing program.st file for OpenPLC Runtime v3...', + }) + const programStPath = join(sourceTargetFolderPath, 'program.st') + + try { + await fs.access(programStPath) + } catch { + throw new Error(`Required file not found: ${programStPath}. Cannot upload to OpenPLC Runtime v3.`) + } + + fileBuffer = await fs.readFile(programStPath) + filename = 'program.st' + contentType = 'text/plain' + } else { + // Clean conf folder from previous compilations to avoid stale config files + await this.cleanConfFolder(sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + + // Generate Modbus Slave config for Runtime v4 + await this.handleGenerateModbusSlaveConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + + // Generate Modbus Master config for Runtime v4 + await this.handleGenerateModbusMasterConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + + // Generate S7Comm config for Runtime v4 + await this.handleGenerateS7CommConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + + // Generate OPC-UA config for Runtime v4 + await this.handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Compressing source files for OpenPLC Runtime v4...', + }) + fileBuffer = await this.compressSourceFolder(sourceTargetFolderPath) + filename = 'program.zip' + contentType = 'application/zip' + } + + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Uploading program to runtime at ${runtimeIpAddress}...`, + }) + + const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2) + + const header = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + + `Content-Type: ${contentType}\r\n\r\n`, + ) + const footer = Buffer.from(`\r\n--${boundary}--\r\n`) + const body = Buffer.concat([header, fileBuffer, footer] as unknown as ReadonlyArray) + + await new Promise((resolve, reject) => { + const req = https.request( + { + hostname: runtimeIpAddress, + port: 8443, + path: '/api/upload-file', + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': body.length, + Authorization: `Bearer ${runtimeJwtToken}`, + }, + ...getRuntimeHttpsOptions(), + } as https.RequestOptions, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + if (res.statusCode === 200) { + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Program uploaded successfully to runtime.', + }) + try { + const response = JSON.parse(data) as { CompilationStatus?: string } + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Runtime compilation started: ${response.CompilationStatus || 'COMPILING'}`, + }) + } catch (_parseError) { + _mainProcessPort.postMessage({ + logLevel: 'warning', + message: 'Could not parse runtime response', + }) + } + + const pollCompilationStatus = async () => { + let lastLogCount = 0 + let shouldContinuePolling = true + const startTime = Date.now() + const timeout = CompilerModule.COMPILATION_STATUS_TIMEOUT_MS + const pollInterval = CompilerModule.COMPILATION_STATUS_POLL_INTERVAL_MS + + while (shouldContinuePolling) { + if (Date.now() - startTime > timeout) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Compilation status polling timed out after 5 minutes.', + }) + shouldContinuePolling = false + continue + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)) + + try { + const result = await mainProcessBridge.makeRuntimeApiRequest<{ + status: string + logs: string[] + exit_code: number | null + }>(runtimeIpAddress, runtimeJwtToken, '/api/compilation-status', (data: string) => { + return JSON.parse(data) as { status: string; logs: string[]; exit_code: number | null } + }) + + if (!result.success) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error polling compilation status: ${result.error}`, + }) + shouldContinuePolling = false + continue + } + + const { status, logs, exit_code } = result.data! + + if (logs.length > lastLogCount) { + const newLogs = logs.slice(lastLogCount) + newLogs.forEach((log) => { + const { level, cleanedMessage } = this.parseLogLevel(log) + _mainProcessPort.postMessage({ + logLevel: level, + message: cleanedMessage, + }) + }) + lastLogCount = logs.length + } + + if (status === 'SUCCESS') { + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Compilation completed successfully (exit code: ${exit_code ?? 0}).`, + }) + shouldContinuePolling = false + } else if (status === 'FAILED') { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Compilation failed (exit code: ${exit_code ?? 1}).`, + }) + shouldContinuePolling = false + } + } catch (pollError) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error polling compilation status: ${pollError instanceof Error ? pollError.message : String(pollError)}`, + }) + shouldContinuePolling = false + } + } + } + + pollCompilationStatus() + .then(async () => { + if (runtimeIpAddress && runtimeJwtToken) { + try { + const statusResult = await mainProcessBridge.makeRuntimeApiRequest( + runtimeIpAddress, + runtimeJwtToken, + '/api/status', + (data: string) => { + const response = JSON.parse(data) as { status: string } + return response.status + }, + ) + + if (statusResult.success && statusResult.data) { + const status = parsePlcStatus(statusResult.data) + if (status) { + _mainProcessPort.postMessage({ + plcStatus: status, + }) + } + } + } catch (_statusError) { + // Silently ignore status check errors - this is a best-effort update + } + } + + _mainProcessPort.postMessage({ + message: + '-------------------------------------------------------------------------------------------------------------\n', + }) + _mainProcessPort.close() + }) + .catch((error) => { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Unexpected error in compilation polling: ${getErrorMessage(error)}`, + }) + _mainProcessPort.postMessage({ + message: + '-------------------------------------------------------------------------------------------------------------\n', + }) + _mainProcessPort.close() + }) + + resolve() + } else { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Upload failed: ${data || 'HTTP ' + res.statusCode}`, + }) + reject(new Error(`Upload failed with status ${res.statusCode}`)) + } + }) + }, + ) + req.setTimeout(300000, () => { + req.destroy() + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Upload request timed out after 5 minutes.', + }) + reject(new Error('Upload timeout')) + }) + req.on('error', (error: Error) => { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Upload error: ${error.message}`, + }) + reject(error) + }) + req.write(body) + req.end() + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Failed to upload to runtime: ${getErrorMessage(error)}`, + }) + _mainProcessPort.postMessage({ + message: + '-------------------------------------------------------------------------------------------------------------\n', + }) + _mainProcessPort.close() + } + return + } + + // Step 7: Handle patch files + try { + await this.handlePatchGeneratedFiles(compilationPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + + // Step 8: Handle core installation + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Handling core installation...' }) + try { + await this.handleCoreInstallation(boardCore, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + // Step 9: Handle library installation + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Handling library installation...' }) + try { + await this.handleLibraryInstallation((data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping compilation process.', + }) + _mainProcessPort.close() + return + } + + // Step 10: Handle defines.h file generation + try { + if (buildMD5Hash === null) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Build MD5 hash is null, cannot generate defines.h file.', + }) + _mainProcessPort.close() + return + } + await this.handleGenerateDefinitionsFile({ + projectPath: normalizedProjectPath, + boardTarget, + buildMD5Hash, + boardRuntime, + _handleOutputData: (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + } + + // Step 11: Generate Arduino CPP file + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Generating Arduino CPP file...' }) + try { + await this.handleGenerateArduinoCppFile(normalizedProjectPath, boardTarget) + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Arduino CPP file generated successfully.' }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.close() + return + } + + // Step 12: Compile Arduino Program + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Compiling Arduino program...' }) + try { + await this.handleCompileArduinoProgram({ + boardTarget, + boardHalsContent: halsContent[boardTarget], + compilationPath, + handleOutputData: (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + }) + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Arduino program compiled successfully.' }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.close() + return + } + + // Step 13: Upload program to board or load into simulator + if (boardRuntime === 'simulator') { + // For simulator targets, send the HEX firmware path back to the renderer. + // Derive the build sub-directory from the platform FQBN (e.g. "arduino:avr:mega" → "arduino.avr.mega") + // so it stays in sync with the hals.json entry. + const fqbnSubDir = halsContent[boardTarget]['platform'].replaceAll(':', '.') + const hexPath = join(compilationPath, 'examples', 'Baremetal', 'build', fqbnSubDir, 'Baremetal.ino.hex') + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Compilation successful. Loading firmware into simulator...', + }) + _mainProcessPort.postMessage({ + simulatorFirmwarePath: hexPath, + closePort: true, + }) + _mainProcessPort.close() + return + } + + if (!compileOnly) { + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Uploading program to board...' }) + try { + await this.handleUploadProgram({ + projectPath: normalizedProjectPath, + arduinoPlatform: halsContent[boardTarget]['platform'], + compilationPath, + handleOutputData: (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }, + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.close() + return + } + } + + // -- Final message -- + _mainProcessPort.postMessage({ + message: + '-------------------------------------------------------------------------------------------------------------\n', + }) + + // INFO: This step is under development. + setTimeout(() => { + _mainProcessPort.close() + }, 25) + } + + async compileForDebugger( + args: Array, + _mainProcessPort: MessagePortMain, + ): Promise { + _mainProcessPort.start() + + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Starting debug compilation process...' }) + + const [projectPath, boardTarget, projectData] = args as [string, string, PLCProjectData] + + const boardRuntime = await this.#getBoardRuntime(boardTarget) + const normalizedProjectPath = projectPath.replace('project.json', '') + const compilationPath = join(normalizedProjectPath, 'build', boardTarget) + const sourceTargetFolderPath = join(compilationPath, 'src') + + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Compiling for debugger - project: ${projectPath}, board: ${boardTarget}`, + }) + + try { + const iec2cCheckResult = await this.checkIec2cAvailability() + _mainProcessPort.postMessage({ + message: `IEC2C available at version ${iec2cCheckResult.data}`, + }) + } catch (_error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${String(_error)}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + + try { + await this.createBasicDirectories(normalizedProjectPath, boardTarget) + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Directories for compilation source files created.', + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `${getErrorMessage(error)}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + + try { + const generateXMLResult = await this.handleGenerateXMLfromJSON(sourceTargetFolderPath, projectData) + _mainProcessPort.postMessage({ + logLevel: 'info', + message: `Generated XML from JSON at: ${generateXMLResult.data?.xmlPath as string}`, + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error generating XML from JSON: ${error as string}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + + const generatedXMLFilePath = join(sourceTargetFolderPath, 'plc.xml') + try { + await this.handleTranspileXMLtoST(generatedXMLFilePath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error transpiling XML to ST: ${error as string}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + + try { + await this.copyStaticFiles(compilationPath, boardRuntime) + _mainProcessPort.postMessage({ logLevel: 'info', message: 'Static files copied successfully.' }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: `Error copying static files: ${error as string}\nStopping debug compilation process.`, + }) + _mainProcessPort.close() + return + } + + const generatedSTFilePath = join(sourceTargetFolderPath, 'program.st') + try { + await this.handleTranspileSTtoC(generatedSTFilePath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping debug compilation process.', + }) + _mainProcessPort.close() + return + } + + try { + await this.handleGenerateDebugFiles(sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping debug compilation process.', + }) + _mainProcessPort.close() + return + } + + try { + await this.handleGenerateGlueVars(sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping debug compilation process.', + }) + _mainProcessPort.close() + return + } + + // Generate C/C++ blocks header file + try { + await this.handleGenerateCBlocksHeader(projectData, sourceTargetFolderPath, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping debug compilation process.', + }) + _mainProcessPort.close() + return + } + + // Generate C/C++ blocks code file + try { + await this.handleGenerateCBlocksCode(projectData, compilationPath, boardRuntime, (data, logLevel) => { + _mainProcessPort.postMessage({ logLevel, message: data }) + }) + } catch (error) { + _mainProcessPort.postMessage({ + logLevel: 'error', + message: typeof error === 'string' ? error : error instanceof Error ? error.message : JSON.stringify(error), + }) + _mainProcessPort.postMessage({ + logLevel: 'error', + message: 'Stopping debug compilation process.', + }) + _mainProcessPort.close() + return + } + + _mainProcessPort.postMessage({ + logLevel: 'info', + message: 'Debug compilation completed successfully.', + }) + _mainProcessPort.postMessage({ + message: + '-------------------------------------------------------------------------------------------------------------\n', + }) + setTimeout(() => { + _mainProcessPort.close() + }, 25) + } +} +export { CompilerModule } diff --git a/src/main/modules/compiler/compiler-types.ts b/src/backend/editor/compiler/compiler-types.ts similarity index 100% rename from src/main/modules/compiler/compiler-types.ts rename to src/backend/editor/compiler/compiler-types.ts diff --git a/src/main/modules/compiler/index.ts b/src/backend/editor/compiler/index.ts similarity index 100% rename from src/main/modules/compiler/index.ts rename to src/backend/editor/compiler/index.ts diff --git a/src/main/modules/compiler/utils/formatters.ts b/src/backend/editor/compiler/utils/formatters.ts similarity index 100% rename from src/main/modules/compiler/utils/formatters.ts rename to src/backend/editor/compiler/utils/formatters.ts diff --git a/src/main/contracts/dtos/index.ts b/src/backend/editor/contracts/dtos/index.ts similarity index 100% rename from src/main/contracts/dtos/index.ts rename to src/backend/editor/contracts/dtos/index.ts diff --git a/src/main/contracts/dtos/services/base-response.dto.ts b/src/backend/editor/contracts/dtos/services/base-response.dto.ts similarity index 100% rename from src/main/contracts/dtos/services/base-response.dto.ts rename to src/backend/editor/contracts/dtos/services/base-response.dto.ts diff --git a/src/main/contracts/dtos/services/project/create-project.dto.ts b/src/backend/editor/contracts/dtos/services/project/create-project.dto.ts similarity index 100% rename from src/main/contracts/dtos/services/project/create-project.dto.ts rename to src/backend/editor/contracts/dtos/services/project/create-project.dto.ts diff --git a/src/main/contracts/dtos/services/project/open-project.dto.ts b/src/backend/editor/contracts/dtos/services/project/open-project.dto.ts similarity index 100% rename from src/main/contracts/dtos/services/project/open-project.dto.ts rename to src/backend/editor/contracts/dtos/services/project/open-project.dto.ts diff --git a/src/main/contracts/dtos/services/project/save-project.dto.ts b/src/backend/editor/contracts/dtos/services/project/save-project.dto.ts similarity index 100% rename from src/main/contracts/dtos/services/project/save-project.dto.ts rename to src/backend/editor/contracts/dtos/services/project/save-project.dto.ts diff --git a/src/main/contracts/dtos/theme.dto.ts b/src/backend/editor/contracts/dtos/theme.dto.ts similarity index 100% rename from src/main/contracts/dtos/theme.dto.ts rename to src/backend/editor/contracts/dtos/theme.dto.ts diff --git a/src/backend/editor/contracts/types/child-window.ts b/src/backend/editor/contracts/types/child-window.ts new file mode 100644 index 000000000..990cc61d7 --- /dev/null +++ b/src/backend/editor/contracts/types/child-window.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' + +import { CONSTANTS } from '../../../utils' + +const { paths } = CONSTANTS as { paths: Record } + +export const ChildWindowSchema = z.object({ + parentWindow: z.any().optional(), + path: z.string().refine((theme) => Object.values(paths).includes(theme)), + hideMenuBar: z.boolean().optional(), + width: z.number().optional(), + height: z.number().optional(), + x: z.number().optional(), + y: z.number().optional(), + center: z.boolean().optional(), + minWidth: z.number().optional(), + minHeight: z.number().optional(), + maxWidth: z.number().optional(), + maxHeight: z.number().optional(), + resizable: z.boolean().optional(), + movable: z.boolean().optional(), + minimizable: z.boolean().optional(), + maximizable: z.boolean().optional(), + closable: z.boolean().optional(), + focusable: z.boolean().optional(), + alwaysOnTop: z.boolean().optional(), + fullscreen: z.boolean().optional(), + fullscreenable: z.boolean().optional(), + simpleFullscreen: z.boolean().optional(), + skipTaskbar: z.boolean().optional(), + title: z.string().optional(), + frame: z.boolean().optional(), + modal: z.boolean().optional(), + autoHideMenuBar: z.boolean().optional(), + backgroundColor: z.string().optional(), + hasShadow: z.boolean().optional(), + opacity: z.number().optional(), + transparent: z.boolean().optional(), + type: z.string().optional(), + titleBarStyle: z.enum(['default', 'hidden', 'hiddenInset', 'customButtonsOnHover']).optional(), +}) + +export type ChildWindowProps = z.infer diff --git a/src/backend/editor/contracts/types/modules/ipc/main.ts b/src/backend/editor/contracts/types/modules/ipc/main.ts new file mode 100644 index 000000000..59e982ce4 --- /dev/null +++ b/src/backend/editor/contracts/types/modules/ipc/main.ts @@ -0,0 +1,30 @@ +import { BrowserWindow, IpcMain } from 'electron/main' + +import MenuBuilder from '../../../../../../main/menu' +import { CompilerModule } from '../../../../compiler' +import { HardwareModule } from '../../../../hardware' +import { PouService, ProjectService } from '../../../../services' +import { TStoreType } from '../store' + +export type MainIpcModule = { + ipcMain: IpcMain + mainWindow: InstanceType | null + projectService: InstanceType + store: TStoreType + setupMainIpcListener: () => void + mainIpcEventHandlers: { + handleUpdateTheme: () => void + createPou: () => void + } +} + +export type MainIpcModuleConstructor = { + ipcMain: IpcMain + mainWindow: InstanceType | null + pouService: InstanceType + projectService: InstanceType + store: TStoreType + menuBuilder: InstanceType + compilerModule: InstanceType + hardwareModule: InstanceType +} diff --git a/src/main/contracts/types/modules/ipc/toast.ts b/src/backend/editor/contracts/types/modules/ipc/toast.ts similarity index 100% rename from src/main/contracts/types/modules/ipc/toast.ts rename to src/backend/editor/contracts/types/modules/ipc/toast.ts diff --git a/src/main/contracts/types/modules/store.ts b/src/backend/editor/contracts/types/modules/store.ts similarity index 100% rename from src/main/contracts/types/modules/store.ts rename to src/backend/editor/contracts/types/modules/store.ts diff --git a/src/main/contracts/types/services/project.service.d.ts b/src/backend/editor/contracts/types/services/project.service.d.ts similarity index 100% rename from src/main/contracts/types/services/project.service.d.ts rename to src/backend/editor/contracts/types/services/project.service.d.ts diff --git a/src/main/contracts/types/services/response.d.ts b/src/backend/editor/contracts/types/services/response.d.ts similarity index 100% rename from src/main/contracts/types/services/response.d.ts rename to src/backend/editor/contracts/types/services/response.d.ts diff --git a/src/main/contracts/types/theme.ts b/src/backend/editor/contracts/types/theme.ts similarity index 100% rename from src/main/contracts/types/theme.ts rename to src/backend/editor/contracts/types/theme.ts diff --git a/src/main/contracts/types/toast.ts b/src/backend/editor/contracts/types/toast.ts similarity index 100% rename from src/main/contracts/types/toast.ts rename to src/backend/editor/contracts/types/toast.ts diff --git a/src/main/contracts/validations/index.ts b/src/backend/editor/contracts/validations/index.ts similarity index 100% rename from src/main/contracts/validations/index.ts rename to src/backend/editor/contracts/validations/index.ts diff --git a/src/main/contracts/validations/store-validation.ts b/src/backend/editor/contracts/validations/store-validation.ts similarity index 100% rename from src/main/contracts/validations/store-validation.ts rename to src/backend/editor/contracts/validations/store-validation.ts diff --git a/src/backend/editor/contracts/validations/theme-validation.ts b/src/backend/editor/contracts/validations/theme-validation.ts new file mode 100644 index 000000000..b1958ef23 --- /dev/null +++ b/src/backend/editor/contracts/validations/theme-validation.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +import { CONSTANTS } from '../../../utils' + +const { + theme: { variants }, +} = CONSTANTS as { theme: { variants: Record } } + +const ThemeSchema = z.string().refine((theme) => Object.values(variants).includes(theme)) + +export default ThemeSchema diff --git a/src/main/modules/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts similarity index 100% rename from src/main/modules/hardware/hardware-module.ts rename to src/backend/editor/hardware/hardware-module.ts diff --git a/src/main/modules/hardware/hardware-types.ts b/src/backend/editor/hardware/hardware-types.ts similarity index 100% rename from src/main/modules/hardware/hardware-types.ts rename to src/backend/editor/hardware/hardware-types.ts diff --git a/src/main/modules/hardware/index.ts b/src/backend/editor/hardware/index.ts similarity index 100% rename from src/main/modules/hardware/index.ts rename to src/backend/editor/hardware/index.ts diff --git a/src/backend/editor/modbus/modbus-client.ts b/src/backend/editor/modbus/modbus-client.ts new file mode 100644 index 000000000..f794da4ae --- /dev/null +++ b/src/backend/editor/modbus/modbus-client.ts @@ -0,0 +1,327 @@ +import { getErrorMessage } from '@root/utils/get-error-message' +import { Socket } from 'net' + +export enum ModbusFunctionCode { + DEBUG_INFO = 0x41, + DEBUG_SET = 0x42, + DEBUG_GET = 0x43, + DEBUG_GET_LIST = 0x44, + DEBUG_GET_MD5 = 0x45, +} + +export enum ModbusDebugResponse { + SUCCESS = 0x7e, + ERROR_OUT_OF_BOUNDS = 0x81, + ERROR_OUT_OF_MEMORY = 0x82, +} + +interface ModbusTcpClientOptions { + host: string + port: number + timeout: number +} + +export class ModbusTcpClient { + private host: string + private port: number + private timeout: number + private socket: Socket | null = null + private transactionId: number = 0 + private sendRequestMutex: Promise = Promise.resolve() + + constructor(options: ModbusTcpClientOptions) { + this.host = options.host + this.port = options.port + this.timeout = options.timeout + } + + private incrementTransactionId(): number { + this.transactionId = (this.transactionId + 1) % 65536 + return this.transactionId + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this.socket = new Socket() + + const timeoutHandle = setTimeout(() => { + this.socket?.destroy() + reject(new Error('Connection timeout')) + }, this.timeout) + + this.socket.connect(this.port, this.host, () => { + clearTimeout(timeoutHandle) + resolve() + }) + + this.socket.on('error', (error) => { + clearTimeout(timeoutHandle) + reject(error) + }) + }) + } + + disconnect(): void { + if (this.socket) { + this.socket.destroy() + this.socket = null + } + } + + private sendTcpRequestImpl(request: Buffer): Promise { + return new Promise((resolve, reject) => { + if (!this.socket) { + reject(new Error('Not connected to target')) + return + } + + const timeoutHandle = setTimeout(() => { + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + reject(new Error('Request timeout')) + }, this.timeout) + + const onData = (data: Buffer) => { + clearTimeout(timeoutHandle) + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + resolve(data) + } + + const onError = (error: Error) => { + clearTimeout(timeoutHandle) + this.socket?.removeListener('data', onData) + this.socket?.removeListener('error', onError) + reject(error) + } + + this.socket.once('data', onData) + this.socket.once('error', onError) + this.socket.write(request as unknown as Uint8Array) + }) + } + + private sendTcpRequest(request: Buffer): Promise { + return new Promise((resolve, reject) => { + this.sendRequestMutex = this.sendRequestMutex.then( + () => this.sendTcpRequestImpl(request).then(resolve, reject), + () => this.sendTcpRequestImpl(request).then(resolve, reject), + ) + }) + } + + async getMd5Hash(): Promise { + if (!this.socket) { + throw new Error('Not connected to target') + } + + const transactionId = this.incrementTransactionId() + const protocolId = 0x0000 + const unitId = 0x00 + const functionCode = ModbusFunctionCode.DEBUG_GET_MD5 + const endiannessCheck = 0xdead + + const request = Buffer.alloc(12) + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(protocolId, 2) + request.writeUInt16BE(6, 4) + request.writeUInt8(unitId, 6) + request.writeUInt8(functionCode, 7) + request.writeUInt16BE(endiannessCheck, 8) + request.writeUInt8(0, 10) + request.writeUInt8(0, 11) + + const data = await this.sendTcpRequest(request) + + if (data.length < 9) { + throw new Error('Invalid response: too short') + } + + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) + + if (responseTransactionId !== transactionId) { + throw new Error('Transaction ID mismatch') + } + + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { + throw new Error('Function code mismatch') + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + throw new Error(`Target returned error code: 0x${statusCode.toString(16)}`) + } + + const md5String = data.slice(9).toString('utf-8').trim() + return md5String + } + + async getVariablesList(variableIndexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: Buffer + error?: string + }> { + if (!this.socket) { + return { success: false, error: 'Not connected to target' } + } + + const transactionId = this.incrementTransactionId() + const protocolId = 0x0000 + const unitId = 0x00 + const functionCode = ModbusFunctionCode.DEBUG_GET_LIST + const numIndexes = variableIndexes.length + + const pduLength = 4 + 2 * numIndexes + const request = Buffer.alloc(6 + pduLength) + + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(protocolId, 2) + request.writeUInt16BE(pduLength, 4) + request.writeUInt8(unitId, 6) + request.writeUInt8(functionCode, 7) + request.writeUInt16BE(numIndexes, 8) + + for (let i = 0; i < numIndexes; i++) { + request.writeUInt16BE(variableIndexes[i], 10 + i * 2) + } + + try { + const data = await this.sendTcpRequest(request) + + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) + + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { + return { success: false, error: 'Function code mismatch' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + if (data.length < 17) { + return { + success: false, + error: `Incomplete success response (${data.length} bytes, expected at least 17)`, + } + } + + const lastIndex = data.readUInt16BE(9) + const tick = data.readUInt32BE(11) + const responseSize = data.readUInt16BE(15) + + if (data.length < 17 + responseSize) { + return { + success: false, + error: `Incomplete variable data (expected ${responseSize} bytes, got ${data.length - 17})`, + } + } + + const variableData = data.slice(17, 17 + responseSize) + + return { + success: true, + tick, + lastIndex, + data: variableData, + } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + async setVariable( + variableIndex: number, + force: boolean, + valueBuffer?: Buffer, + ): Promise<{ + success: boolean + error?: string + }> { + if (!this.socket) { + return { success: false, error: 'Not connected to target' } + } + + const transactionId = this.incrementTransactionId() + const protocolId = 0x0000 + const unitId = 0x00 + const functionCode = ModbusFunctionCode.DEBUG_SET + + const dataLength = force && valueBuffer ? valueBuffer.length : 1 + const pduLength = 7 + dataLength + const request = Buffer.alloc(6 + pduLength) + + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(protocolId, 2) + request.writeUInt16BE(pduLength, 4) + request.writeUInt8(unitId, 6) + request.writeUInt8(functionCode, 7) + request.writeUInt16BE(variableIndex, 8) + request.writeUInt8(force ? 1 : 0, 10) + request.writeUInt16BE(dataLength, 11) + + if (force && valueBuffer) { + for (let i = 0; i < valueBuffer.length; i++) { + request.writeUInt8(valueBuffer[i], 13 + i) + } + } else { + request.writeUInt8(0, 13) + } + + try { + const data = await this.sendTcpRequest(request) + + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + + const responseTransactionId = data.readUInt16BE(0) + const responseFunctionCode = data.readUInt8(7) + const statusCode = data.readUInt8(8) + + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_SET as number)) { + return { success: false, error: 'Function code mismatch' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + return { success: true } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } +} diff --git a/src/backend/editor/modbus/modbus-rtu-client.ts b/src/backend/editor/modbus/modbus-rtu-client.ts new file mode 100644 index 000000000..f419af835 --- /dev/null +++ b/src/backend/editor/modbus/modbus-rtu-client.ts @@ -0,0 +1,433 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - serialport types are not available at build time but will be at runtime +import { getErrorMessage } from '@root/utils/get-error-message' +import { SerialPort } from 'serialport' + +import { ModbusDebugResponse, ModbusFunctionCode } from './modbus-client' + +interface ModbusRtuClientOptions { + port: string + baudRate: number + slaveId: number + timeout: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + serialPort?: any // Pre-built serial port (e.g. VirtualSerialPort for simulator) +} + +const ARDUINO_BOOTLOADER_DELAY_MS = 2500 +const MD5_REQUEST_MAX_RETRIES = 3 +const MD5_REQUEST_RETRY_DELAY_MS = 500 + +const FRAME_COMPLETE_TIMEOUT_MS = 10 + +export class ModbusRtuClient { + private port: string + private baudRate: number + private slaveId: number + private timeout: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private serialPort: any = null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private injectedSerialPort: any = null + + private static readonly CRC_HI_TABLE = [ + 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, + 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, + 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, + 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, + 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, + 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, + 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, + 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, + 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, + 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, + 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, + 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, + 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, + 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, + ] + + private static readonly CRC_LO_TABLE = [ + 0x00, 0xc0, 0xc1, 0x01, 0xc3, 0x03, 0x02, 0xc2, 0xc6, 0x06, 0x07, 0xc7, 0x05, 0xc5, 0xc4, 0x04, 0xcc, 0x0c, 0x0d, + 0xcd, 0x0f, 0xcf, 0xce, 0x0e, 0x0a, 0xca, 0xcb, 0x0b, 0xc9, 0x09, 0x08, 0xc8, 0xd8, 0x18, 0x19, 0xd9, 0x1b, 0xdb, + 0xda, 0x1a, 0x1e, 0xde, 0xdf, 0x1f, 0xdd, 0x1d, 0x1c, 0xdc, 0x14, 0xd4, 0xd5, 0x15, 0xd7, 0x17, 0x16, 0xd6, 0xd2, + 0x12, 0x13, 0xd3, 0x11, 0xd1, 0xd0, 0x10, 0xf0, 0x30, 0x31, 0xf1, 0x33, 0xf3, 0xf2, 0x32, 0x36, 0xf6, 0xf7, 0x37, + 0xf5, 0x35, 0x34, 0xf4, 0x3c, 0xfc, 0xfd, 0x3d, 0xff, 0x3f, 0x3e, 0xfe, 0xfa, 0x3a, 0x3b, 0xfb, 0x39, 0xf9, 0xf8, + 0x38, 0x28, 0xe8, 0xe9, 0x29, 0xeb, 0x2b, 0x2a, 0xea, 0xee, 0x2e, 0x2f, 0xef, 0x2d, 0xed, 0xec, 0x2c, 0xe4, 0x24, + 0x25, 0xe5, 0x27, 0xe7, 0xe6, 0x26, 0x22, 0xe2, 0xe3, 0x23, 0xe1, 0x21, 0x20, 0xe0, 0xa0, 0x60, 0x61, 0xa1, 0x63, + 0xa3, 0xa2, 0x62, 0x66, 0xa6, 0xa7, 0x67, 0xa5, 0x65, 0x64, 0xa4, 0x6c, 0xac, 0xad, 0x6d, 0xaf, 0x6f, 0x6e, 0xae, + 0xaa, 0x6a, 0x6b, 0xab, 0x69, 0xa9, 0xa8, 0x68, 0x78, 0xb8, 0xb9, 0x79, 0xbb, 0x7b, 0x7a, 0xba, 0xbe, 0x7e, 0x7f, + 0xbf, 0x7d, 0xbd, 0xbc, 0x7c, 0xb4, 0x74, 0x75, 0xb5, 0x77, 0xb7, 0xb6, 0x76, 0x72, 0xb2, 0xb3, 0x73, 0xb1, 0x71, + 0x70, 0xb0, 0x50, 0x90, 0x91, 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9c, + 0x5c, 0x5d, 0x9d, 0x5f, 0x9f, 0x9e, 0x5e, 0x5a, 0x9a, 0x9b, 0x5b, 0x99, 0x59, 0x58, 0x98, 0x88, 0x48, 0x49, 0x89, + 0x4b, 0x8b, 0x8a, 0x4a, 0x4e, 0x8e, 0x8f, 0x4f, 0x8d, 0x4d, 0x4c, 0x8c, 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, + 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, 0x40, + ] + + constructor(options: ModbusRtuClientOptions) { + this.port = options.port + this.baudRate = options.baudRate + this.slaveId = options.slaveId + this.timeout = options.timeout + this.injectedSerialPort = options.serialPort ?? null + } + + private calculateCrc(buffer: Buffer): number { + let crcHi = 0xff + let crcLo = 0xff + + for (let i = 0; i < buffer.length; i++) { + const index = crcHi ^ buffer[i] + crcHi = crcLo ^ ModbusRtuClient.CRC_HI_TABLE[index] + crcLo = ModbusRtuClient.CRC_LO_TABLE[index] + } + + return (crcHi << 8) | crcLo + } + + private assembleRequest(functionCode: number, data: Buffer): Buffer { + const frameWithoutCrc = Buffer.alloc(2 + data.length) + frameWithoutCrc.writeUInt8(this.slaveId, 0) + frameWithoutCrc.writeUInt8(functionCode, 1) + data.copy(frameWithoutCrc as unknown as Uint8Array, 2) + + const crc = this.calculateCrc(frameWithoutCrc) + const request = Buffer.alloc(frameWithoutCrc.length + 2) + frameWithoutCrc.copy(request as unknown as Uint8Array, 0) + request.writeUInt16BE(crc, frameWithoutCrc.length) + + return request + } + + async connect(): Promise { + // If a pre-built serial port was provided (e.g. VirtualSerialPort), use it directly + if (this.injectedSerialPort) { + this.serialPort = this.injectedSerialPort + return new Promise((resolve, reject) => { + this.serialPort.on('open', () => resolve()) + this.serialPort.on('error', (err: Error) => reject(err)) + this.serialPort.open() + }) + } + + return new Promise((resolve, reject) => { + try { + this.serialPort = new SerialPort({ + path: this.port, + baudRate: this.baudRate, + dataBits: 8, + stopBits: 1, + parity: 'none', + }) + + this.serialPort.on('open', () => { + setTimeout(() => { + resolve() + }, ARDUINO_BOOTLOADER_DELAY_MS) + }) + + this.serialPort.on('error', (error: unknown) => { + reject(error instanceof Error ? error : new Error(getErrorMessage(error) as string)) + }) + } catch (error) { + reject(error instanceof Error ? error : new Error(getErrorMessage(error) as string)) + } + }) + } + + disconnect(): void { + if (this.serialPort && this.serialPort.isOpen) { + this.serialPort.close() + this.serialPort = null + } + } + + private flushInputBuffer(): Promise { + return new Promise((resolve) => { + if (!this.serialPort || !this.serialPort.isOpen) { + resolve() + return + } + + this.serialPort.flush((err: Error | null) => { + if (err) { + console.warn('Warning: Failed to flush serial port:', err.message) + } + resolve() + }) + }) + } + + private sendRequestMutex: Promise = Promise.resolve() + + private async sendRequest(request: Buffer): Promise { + return new Promise((resolve, reject) => { + this.sendRequestMutex = this.sendRequestMutex.then( + () => this.sendRequestImpl(request).then(resolve, reject), + () => this.sendRequestImpl(request).then(resolve, reject), + ) + }) + } + + private async sendRequestImpl(request: Buffer): Promise { + if (!this.serialPort || !this.serialPort.isOpen) { + throw new Error('Serial port is not open') + } + + await this.flushInputBuffer() + + return new Promise((resolve, reject) => { + let responseBuffer = Buffer.alloc(0) + let frameCompleteTimeout: NodeJS.Timeout | null = null + + // Forward-declared so the timeout handler can reference them for cleanup + const cleanup = () => { + this.serialPort?.removeListener('data', onData) + this.serialPort?.removeListener('error', onError) + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + } + } + + const timeoutHandle = setTimeout(() => { + cleanup() + reject(new Error('Request timeout')) + }, this.timeout) + + const onData = (data: Buffer) => { + responseBuffer = Buffer.concat([responseBuffer, data] as unknown as Uint8Array[]) + + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + } + + frameCompleteTimeout = setTimeout(() => { + clearTimeout(timeoutHandle) + cleanup() + + if (responseBuffer.length < 5) { + reject(new Error('Response too short')) + return + } + + const receivedCrc = responseBuffer.readUInt16BE(responseBuffer.length - 2) + const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) + + if (receivedCrc !== calculatedCrc) { + // OpenPLC debugger ignores CRC errors — mismatch is non-fatal + } + + const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) + const paddedResponse = Buffer.alloc(6 + responseWithoutCrc.length) + paddedResponse.fill(0, 0, 6) + responseWithoutCrc.copy(paddedResponse as unknown as Uint8Array, 6) + + resolve(paddedResponse) + }, FRAME_COMPLETE_TIMEOUT_MS) + } + + const onError = (error: Error) => { + clearTimeout(timeoutHandle) + cleanup() + reject(error) + } + + this.serialPort!.on('data', onData) + this.serialPort!.once('error', onError) + this.serialPort!.write(request as unknown as Uint8Array, (error: unknown) => { + if (error) { + clearTimeout(timeoutHandle) + cleanup() + const errorMessage = + typeof error === 'string' + ? error + : typeof error === 'object' && error !== null + ? JSON.stringify(error) + : 'Unknown error' + reject(error instanceof Error ? error : new Error(errorMessage)) + } + }) + }) + } + + async getMd5Hash(): Promise { + const functionCode = ModbusFunctionCode.DEBUG_GET_MD5 + const endiannessCheck = 0xdead + + const data = Buffer.alloc(4) + data.writeUInt16BE(endiannessCheck, 0) + data.writeUInt8(0, 2) + data.writeUInt8(0, 3) + + const request = this.assembleRequest(functionCode, data) + + let lastError: Error | null = null + for (let attempt = 0; attempt <= MD5_REQUEST_MAX_RETRIES; attempt++) { + try { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, MD5_REQUEST_RETRY_DELAY_MS)) + } + + const response = await this.sendRequest(request) + + if (response.length < 9) { + throw new Error('Invalid response: too short') + } + + const functionCodeResponse = response.readUInt8(7) + const statusCode = response.readUInt8(8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { + throw new Error('Function code mismatch') + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + throw new Error(`Target returned error code: 0x${statusCode.toString(16)}`) + } + + const md5String = response.slice(9).toString('utf-8').trim() + return md5String + } catch (error) { + lastError = error instanceof Error ? error : new Error(getErrorMessage(error) as string) + if (attempt < MD5_REQUEST_MAX_RETRIES) { + console.warn(`MD5 request attempt ${attempt + 1} failed: ${lastError.message}. Retrying...`) + } + } + } + + throw Object.assign(new Error('Failed to get MD5 hash after retries'), { cause: lastError }) + } + + async getVariablesList(variableIndexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: Buffer + error?: string + }> { + try { + const functionCode = ModbusFunctionCode.DEBUG_GET_LIST + const numIndexes = variableIndexes.length + + const data = Buffer.alloc(2 + 2 * numIndexes) + data.writeUInt16BE(numIndexes, 0) + + for (let i = 0; i < numIndexes; i++) { + data.writeUInt16BE(variableIndexes[i], 2 + i * 2) + } + + const request = this.assembleRequest(functionCode, data) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const functionCodeResponse = response.readUInt8(7) + const statusCode = response.readUInt8(8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { + return { success: false, error: 'Function code mismatch' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + if (response.length < 17) { + return { + success: false, + error: `Incomplete success response (${response.length} bytes, expected at least 17)`, + } + } + + const lastIndex = response.readUInt16BE(9) + const tick = response.readUInt32BE(11) + const responseSize = response.readUInt16BE(15) + + if (response.length < 17 + responseSize) { + return { + success: false, + error: `Incomplete variable data (expected ${responseSize} bytes, got ${response.length - 17})`, + } + } + + const variableData = response.slice(17, 17 + responseSize) + + return { + success: true, + tick, + lastIndex, + data: variableData, + } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + async setVariable( + variableIndex: number, + force: boolean, + valueBuffer?: Buffer, + ): Promise<{ + success: boolean + error?: string + }> { + try { + const functionCode = ModbusFunctionCode.DEBUG_SET + + const dataLength = force && valueBuffer ? valueBuffer.length : 1 + const data = Buffer.alloc(5 + dataLength) + + data.writeUInt16BE(variableIndex, 0) + data.writeUInt8(force ? 1 : 0, 2) + data.writeUInt16BE(dataLength, 3) + + if (force && valueBuffer) { + for (let i = 0; i < valueBuffer.length; i++) { + data.writeUInt8(valueBuffer[i], 5 + i) + } + } else { + data.writeUInt8(0, 5) + } + + const request = this.assembleRequest(functionCode, data) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const functionCodeResponse = response.readUInt8(7) + const statusCode = response.readUInt8(8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_SET as number)) { + return { success: false, error: 'Function code mismatch' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + return { success: true } + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } +} diff --git a/src/main/services/__tests__/user-service.test.ts b/src/backend/editor/services/__tests__/user-service.test.ts similarity index 100% rename from src/main/services/__tests__/user-service.test.ts rename to src/backend/editor/services/__tests__/user-service.test.ts diff --git a/src/main/services/index.ts b/src/backend/editor/services/index.ts similarity index 100% rename from src/main/services/index.ts rename to src/backend/editor/services/index.ts diff --git a/src/main/services/logger-service/index.ts b/src/backend/editor/services/logger-service/index.ts similarity index 100% rename from src/main/services/logger-service/index.ts rename to src/backend/editor/services/logger-service/index.ts diff --git a/src/backend/editor/services/pou-service/index.ts b/src/backend/editor/services/pou-service/index.ts new file mode 100644 index 000000000..5aa5a17da --- /dev/null +++ b/src/backend/editor/services/pou-service/index.ts @@ -0,0 +1,159 @@ +import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' +import { PLCPou } from '@root/types/PLC/open-plc' +import { getExtensionFromLanguage } from '@root/utils/PLC/pou-file-extensions' +import { serializePouToText } from '@root/utils/PLC/pou-text-serializer' +import { promises } from 'fs' +import { basename, dirname, join } from 'path' + +import { ipcPouToFlat } from '../../utils' +import { UserService } from '../user-service' + +class PouService { + constructor() {} + + async createPouFile(props: CreatePouFileProps): Promise { + const { pou } = props + const flat = ipcPouToFlat(pou) + const extension = getExtensionFromLanguage(flat.body.language) + const filePath = join(dirname(props.path), `${flat.name}${extension}`) + + try { + await promises.access(filePath) + return { + success: false, + error: { + title: 'POU Create Error', + description: 'A file with the target name already exists', + error: new Error('EEXIST'), + }, + } + } catch { + // File does not exist, proceed with creation + // If the file does not exist, we can create it + // No action needed here, just continue + } + + try { + await UserService.createDirectoryIfNotExists(dirname(filePath)) + const textContent: string = serializePouToText(flat) + await promises.writeFile(filePath, textContent, 'utf-8') + + return { success: true, data: { pou } } + } catch (error) { + console.error('Error creating POU file:', error) + return { success: false, error: { title: 'POU Creation Error', description: 'Failed to create POU file', error } } + } + } + + async deletePouFile(filePath: string): Promise { + try { + await UserService.deleteFile(filePath) + } catch (error) { + console.error('Error deleting POU file:', error) + return { success: false, error: { title: 'POU Deletion Error', description: 'Failed to delete POU file', error } } + } + return { success: true } + } + + async renamePouFile(data: { + filePath: string + newFileName: string + fileContent?: unknown + }): Promise { + const { filePath, newFileName, fileContent } = data + const safeNewFileName = basename(newFileName) + if (!safeNewFileName || safeNewFileName === '.' || safeNewFileName === '..') { + return { + success: false, + error: { + title: 'POU Rename Error', + description: 'Invalid target file name', + error: new Error('EINVAL'), + }, + } + } + + // Determine actual file paths by converting .json virtual paths to real language extensions + let actualOldFilePath = filePath + let actualNewFilePath = join(dirname(filePath), safeNewFileName) + + const isPou = + typeof fileContent === 'object' && fileContent !== null && 'type' in fileContent && 'data' in fileContent + + if (isPou) { + const flat = ipcPouToFlat(fileContent as PLCPou) + const extension: string = getExtensionFromLanguage(flat.body.language) + + // Convert .json paths to actual language extension paths + if (filePath.endsWith('.json')) { + actualOldFilePath = filePath.replace(/\.json$/, extension) + } + if (safeNewFileName.endsWith('.json')) { + const newFileNameWithExtension = safeNewFileName.replace(/\.json$/, extension) + actualNewFilePath = join(dirname(filePath), newFileNameWithExtension) + } + } + + try { + await promises.access(actualNewFilePath) + return { + success: false, + error: { + title: 'POU Rename Error', + description: 'A file with the target name already exists', + error: new Error('EEXIST'), + }, + } + } catch { + // File does not exist, proceed with renaming + // No action needed here, just continue + } + + if (fileContent && isPou) { + try { + const flat = ipcPouToFlat(fileContent as PLCPou) + const textContent: string = serializePouToText(flat) + await promises.writeFile(actualOldFilePath, textContent, 'utf-8') + } catch (writeError) { + console.error(`Error writing content before rename: ${String(writeError)}`) + return { + success: false, + error: { + title: 'File Write Error', + description: 'Failed to update content before rename', + error: writeError as Error, + }, + } + } + } else if (fileContent) { + try { + await promises.writeFile(actualOldFilePath, JSON.stringify(fileContent, null, 2)) + } catch (writeError) { + console.error(`Error writing content before rename: ${String(writeError)}`) + return { + success: false, + error: { + title: 'File Write Error', + description: 'Failed to update content before rename', + error: writeError as Error, + }, + } + } + } + + try { + const result = await UserService.renameFile(actualOldFilePath, actualNewFilePath) + if (!result.success) { + console.error('Error renaming POU file:', result.error) + return { success: false, error: result.error } + } + } catch (error) { + console.error('Error renaming POU file:', error) + return { success: false, error: { title: 'POU Rename Error', description: 'Failed to rename POU file', error } } + } + + return { success: true, data: { filePath: actualNewFilePath } } + } +} + +export { PouService } diff --git a/src/main/services/project-service/data/index.ts b/src/backend/editor/services/project-service/data/index.ts similarity index 100% rename from src/main/services/project-service/data/index.ts rename to src/backend/editor/services/project-service/data/index.ts diff --git a/src/main/services/project-service/data/xml-file.ts b/src/backend/editor/services/project-service/data/xml-file.ts similarity index 100% rename from src/main/services/project-service/data/xml-file.ts rename to src/backend/editor/services/project-service/data/xml-file.ts diff --git a/src/backend/editor/services/project-service/index.ts b/src/backend/editor/services/project-service/index.ts new file mode 100644 index 000000000..4660d5391 --- /dev/null +++ b/src/backend/editor/services/project-service/index.ts @@ -0,0 +1,489 @@ +import { + CreateProjectFileProps, + IProjectRecentHistoryEntry, + IProjectServiceResponse, +} from '@root/types/IPC/project-service' +import { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' +import { getExtensionFromLanguage } from '@root/utils/PLC/pou-file-extensions' +import { serializePouToText } from '@root/utils/PLC/pou-text-serializer' +import { app, BrowserWindow, dialog } from 'electron' +import { promises } from 'fs' +import { dirname, join, normalize } from 'path' + +import { PLCPou, PLCProject, PLCRemoteDevice, PLCServer } from '../../../types/PLC/open-plc' +import { fileOrDirectoryExists, ipcPouToFlat } from '../../utils' +import { createProjectDefaultStructure, readProjectFiles } from './utils' + +class ProjectService { + constructor(private serviceManager: InstanceType) {} + + public getHistoryProjectsFilePath(): string { + const pathToUserDataFolder = join(app.getPath('userData'), 'User') + const pathToUserHistoryFolder = join(pathToUserDataFolder, 'History') + + return join(pathToUserHistoryFolder, 'projects.json') + } + + async getProjectName(projectPath: string): Promise { + try { + const projectFile = await promises.readFile(projectPath, 'utf-8') + return ((JSON.parse(projectFile) as PLCProject).meta.name as string) || 'Unknown project' + } catch { + console.error('Error reading project file', projectPath) + return 'Unknown project' + } + } + + async createProject(data: CreateProjectFileProps): Promise { + const projectDefaultDirectoriesResponse = createProjectDefaultStructure(data.path, data) + if (!projectDefaultDirectoriesResponse.success || !projectDefaultDirectoriesResponse.data) { + return { + success: false, + error: projectDefaultDirectoriesResponse.error, + } + } + await this.updateProjectHistory(data.path) + return { + success: true, + data: { + meta: { + path: data.path, // Use the directory path instead of projectPath + }, + content: projectDefaultDirectoriesResponse.data.content, + }, + } + } + + async readProjectHistory(historyProjectsFilePath: string): Promise { + try { + const historyContent = await promises.readFile(historyProjectsFilePath, 'utf-8') + const content = (JSON.parse(historyContent) as IProjectRecentHistoryEntry[]) || [] + return content.map((entry) => ({ + ...entry, + path: normalize(entry.path).endsWith('/project.json') + ? normalize(entry.path).slice(0, -'/project.json'.length) + : normalize(entry.path), + projectFilePath: entry.projectFilePath + ? normalize(entry.projectFilePath).endsWith('/project.json') + ? normalize(entry.projectFilePath).slice(0, -'/project.json'.length) + : normalize(entry.projectFilePath) + : '', + })) + } catch (error) { + console.error('Error reading history file:', error) + return [] + } + } + + private async writeProjectHistory( + projectsFilePath: string, + historyData: IProjectRecentHistoryEntry[], + ): Promise { + await promises.writeFile(projectsFilePath, JSON.stringify(historyData, null, 2)) + } + + async updateProjectHistory(projectPath: string): Promise { + const historyProjectsFilePath = this.getHistoryProjectsFilePath() + + const directoryPath = projectPath.endsWith('/project.json') + ? projectPath.slice(0, -'/project.json'.length) + : projectPath + const projectFilePath = projectPath.endsWith('/project.json') ? projectPath : join(projectPath, 'project.json') + + const projectName = await this.getProjectName(projectFilePath) + const historyData = await this.readProjectHistory(historyProjectsFilePath) + const lastOpenedAt = new Date().toISOString() + + const existingProjectIndex = historyData.findIndex((proj) => proj.path === directoryPath) + if (existingProjectIndex > -1) { + historyData[existingProjectIndex].name = projectName + historyData[existingProjectIndex].path = directoryPath + historyData[existingProjectIndex].projectFilePath = projectFilePath + historyData[existingProjectIndex].lastOpenedAt = lastOpenedAt + } else { + historyData.push({ + name: projectName, + path: directoryPath, + projectFilePath: projectFilePath, + createdAt: lastOpenedAt, + lastOpenedAt, + }) + } + + historyData.sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()) + await this.writeProjectHistory(historyProjectsFilePath, historyData) + } + + async removeProjectFromHistory(projectPath: string): Promise { + const historyProjectsFilePath = this.getHistoryProjectsFilePath() + const historyData = await this.readProjectHistory(historyProjectsFilePath) + const updatedHistory = historyData.filter((project) => project.path !== projectPath) + await this.writeProjectHistory(historyProjectsFilePath, updatedHistory) + } + + async openProjectByPath(projectPath: string): Promise { + try { + await promises.access(projectPath) + const projectFiles = await readProjectFiles(projectPath) + + if (!projectFiles.success || !projectFiles.data) { + console.error(`Error opening project at path: ${projectPath}`, projectFiles.error) + await this.removeProjectFromHistory(projectPath) + + return { + success: false, + error: { + title: 'Failed to read project', + description: 'Could not read the project. Please check the project directory.', + error: projectFiles.error, + }, + } + } + + await this.updateProjectHistory(projectPath) + + return { + success: true, + data: { + meta: { + path: projectPath, + }, + content: projectFiles.data, + }, + } + } catch (error) { + console.error(`Error opening project at path: ${projectPath}`, error) + await this.removeProjectFromHistory(projectPath) + + return { + success: false, + error: { + title: 'Failed to read project', + description: 'Could not read the project. Please check the project directory.', + error: error, + }, + } + } + } + + async openProject(): Promise { + const { canceled, filePaths } = await dialog.showOpenDialog(this.serviceManager, { + title: 'Select a PLC project to open', + properties: ['openDirectory'], + }) + + if (canceled) { + return { + success: false, + error: { + title: 'Operation canceled', + description: 'Operation canceled by the user.', + error: null, + }, + } + } + + const [directoryPath] = filePaths + + try { + await promises.access(directoryPath) + const projectFiles = await readProjectFiles(directoryPath) + + if (!projectFiles.success || !projectFiles.data) { + console.error(`Error opening project at path: ${directoryPath}`, projectFiles.error) + await this.removeProjectFromHistory(directoryPath) + + return { + success: false, + error: { + title: 'Failed to read project', + description: 'Could not read the project. Please check the project directory.', + error: projectFiles.error, + }, + } + } + + await this.updateProjectHistory(directoryPath) + + return { + success: true, + data: { + meta: { + path: directoryPath, + }, + content: projectFiles.data, + }, + } + } catch (error) { + console.error(`Error accessing project directory: ${filePaths[0]}`, error) + await this.removeProjectFromHistory(directoryPath) + + return { + success: false, + error: { + title: 'Failed to read project', + description: 'Could not read the project. Please check the project directory.', + error: error, + }, + } + } + } + + async saveProject(data: { + projectPath: string + content: { + projectData: PLCProject + pous: PLCPou[] + deviceConfiguration: DeviceConfiguration + devicePinMapping: DevicePin[] + servers?: PLCServer[] + remoteDevices?: PLCRemoteDevice[] + } + }): Promise { + const { + projectPath, + content: { deviceConfiguration, devicePinMapping, projectData, servers, remoteDevices }, + } = data + if (!projectPath || !projectData) { + return { + success: false, + error: { + title: 'Missing parameters', + description: 'Missing parameters', + error: null, + }, + } + } + + const directoryPath = projectPath.endsWith('/project.json') + ? projectPath.slice(0, -'/project.json'.length) + : projectPath + + try { + // Write each part to its correct file based on projectDefaultFilesMapSchema + await Promise.all([ + promises.writeFile(join(directoryPath, 'project.json'), JSON.stringify(projectData, null, 2)), + promises.writeFile( + join(directoryPath, 'devices/configuration.json'), + JSON.stringify(deviceConfiguration, null, 2), + ), + promises.writeFile(join(directoryPath, 'devices/pin-mapping.json'), JSON.stringify(devicePinMapping, null, 2)), + ]) + } catch (error) { + console.error(error) + return { + success: false, + error: { + title: 'Failed to save file', + description: 'Unable to save the project file.', + error, + }, + } + } + + // Save pous + try { + const savedPous = { + programs: data.content.pous.filter((pou) => pou.type === 'program'), + functions: data.content.pous.filter((pou) => pou.type === 'function'), + 'function-blocks': data.content.pous.filter((pou) => pou.type === 'function-block'), + } + + // Save each POU in its respective folder + for (const [type, pous] of Object.entries(savedPous)) { + const dir = join(directoryPath, 'pous', type) + + if (!fileOrDirectoryExists(dir)) { + await promises.mkdir(dir, { recursive: true }) + } + + // Write/update each POU file + for (const pou of pous) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const flat = ipcPouToFlat(pou) + const extension: string = getExtensionFromLanguage(flat.body.language) + const filePath = join(dir, `${flat.name}${extension}`) + const textContent: string = serializePouToText(flat) + await promises.writeFile(filePath, textContent, 'utf-8') + } + } + + if (projectData.data.deletedPous && projectData.data.deletedPous.length > 0) { + for (const deletedPou of projectData.data.deletedPous) { + const typeDir = + deletedPou.type === 'function' + ? 'functions' + : deletedPou.type === 'function-block' + ? 'function-blocks' + : 'programs' + const extension = getExtensionFromLanguage(deletedPou.language) + const filePath = join(directoryPath, 'pous', typeDir, `${deletedPou.name}${extension}`) + + try { + if (fileOrDirectoryExists(filePath)) { + await promises.unlink(filePath) + } + } catch (deleteError) { + console.error(`Error deleting POU file ${filePath}:`, deleteError) + } + + const jsonFilePath = join(directoryPath, 'pous', typeDir, `${deletedPou.name}.json`) + try { + if (fileOrDirectoryExists(jsonFilePath)) { + await promises.unlink(jsonFilePath) + } + } catch (deleteError) { + console.error(`Error deleting legacy JSON POU file ${jsonFilePath}:`, deleteError) + } + } + } + } catch (error) { + console.error('Error saving POUs:', error) + return { + success: false, + error: { + title: 'Failed to save file', + description: 'Unable to save the project file.', + error, + }, + } + } + + // Save servers + if ( + (servers && servers.length > 0) || + (projectData.data.deletedServers && projectData.data.deletedServers.length > 0) + ) { + try { + const serversDir = join(directoryPath, 'devices', 'servers') + if (!fileOrDirectoryExists(serversDir)) { + await promises.mkdir(serversDir, { recursive: true }) + } + + if (servers && servers.length > 0) { + for (const server of servers) { + const serverFilePath = join(serversDir, `${server.name}.json`) + await promises.writeFile(serverFilePath, JSON.stringify(server, null, 2), 'utf-8') + } + } + + // Handle deleted servers + if (projectData.data.deletedServers && projectData.data.deletedServers.length > 0) { + for (const deletedServer of projectData.data.deletedServers) { + const serverFilePath = join(serversDir, `${deletedServer.name}.json`) + try { + if (fileOrDirectoryExists(serverFilePath)) { + await promises.unlink(serverFilePath) + } + } catch (deleteError) { + console.error(`Error deleting server file ${serverFilePath}:`, deleteError) + } + } + } + } catch (error) { + console.error('Error saving servers:', error) + return { + success: false, + error: { + title: 'Failed to save file', + description: 'Unable to save the project file.', + error, + }, + } + } + } + + // Save remote devices + if ( + (remoteDevices && remoteDevices.length > 0) || + (projectData.data.deletedRemoteDevices && projectData.data.deletedRemoteDevices.length > 0) + ) { + try { + const remoteDevicesDir = join(directoryPath, 'devices', 'remote') + if (!fileOrDirectoryExists(remoteDevicesDir)) { + await promises.mkdir(remoteDevicesDir, { recursive: true }) + } + + if (remoteDevices && remoteDevices.length > 0) { + for (const remoteDevice of remoteDevices) { + const remoteDeviceFilePath = join(remoteDevicesDir, `${remoteDevice.name}.json`) + await promises.writeFile(remoteDeviceFilePath, JSON.stringify(remoteDevice, null, 2), 'utf-8') + } + } + + // Handle deleted remote devices + if (projectData.data.deletedRemoteDevices && projectData.data.deletedRemoteDevices.length > 0) { + for (const deletedRemoteDevice of projectData.data.deletedRemoteDevices) { + const remoteDeviceFilePath = join(remoteDevicesDir, `${deletedRemoteDevice.name}.json`) + try { + if (fileOrDirectoryExists(remoteDeviceFilePath)) { + await promises.unlink(remoteDeviceFilePath) + } + } catch (deleteError) { + console.error(`Error deleting remote device file ${remoteDeviceFilePath}:`, deleteError) + } + } + } + } catch (error) { + console.error('Error saving remote devices:', error) + return { + success: false, + error: { + title: 'Failed to save file', + description: 'Unable to save the project file.', + error, + }, + } + } + } + + return { + success: true, + message: 'Your project was saved successfully', + } + } + + async saveFile(filePath: string, content: unknown): Promise { + try { + if (!fileOrDirectoryExists(filePath)) { + const dir = dirname(filePath) + await promises.mkdir(dir, { recursive: true }) + } + + const isPou = typeof content === 'object' && content !== null && 'type' in content && 'data' in content + + if (isPou) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const flat = ipcPouToFlat(content as PLCPou) + + let actualFilePath = filePath + if (filePath.endsWith('.json')) { + const extension: string = getExtensionFromLanguage(flat.body.language) + actualFilePath = filePath.replace(/\.json$/, extension) + } + + const textContent: string = serializePouToText(flat) + await promises.writeFile(actualFilePath, textContent, 'utf-8') + } else { + await promises.writeFile(filePath, JSON.stringify(content, null, 2)) + } + + return { + success: true, + message: 'Your project was saved successfully', + } + } catch (error) { + console.error('Error saving file:', error) + return { + success: false, + error: { + title: 'Failed to save file', + description: 'Unable to save the project file.', + error, + }, + } + } + } +} + +export { ProjectService } diff --git a/src/backend/editor/services/project-service/utils/create-project.ts b/src/backend/editor/services/project-service/utils/create-project.ts new file mode 100644 index 000000000..753378852 --- /dev/null +++ b/src/backend/editor/services/project-service/utils/create-project.ts @@ -0,0 +1,245 @@ +import { + CreateProjectDefaultDirectoriesResponse, + CreateProjectFileProps, + projectDefaultDirectories, + projectDefaultFilesMapSchema, +} from '@root/types/IPC/project-service' +import { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' +import { PLCPou, PLCProject } from '@root/types/PLC/open-plc' +import { getDefaultSchemaValues } from '@root/utils/default-zod-schema-values' +import { getExtensionFromLanguage } from '@root/utils/PLC/pou-file-extensions' +import { serializePouToText } from '@root/utils/PLC/pou-text-serializer' +import { writeFileSync } from 'fs' + +import { createDirectory, fileOrDirectoryExists, ipcPouToFlat } from '../../../utils' +import { CreateJSONFile } from '../../../utils' + +const definePou = (language: CreateProjectFileProps['language']): PLCPou => ({ + type: 'program', + data: { + name: 'main', + language: language, + variables: [], + documentation: '', + body: (() => { + switch (language) { + case 'ld': + return { language, value: { name: 'main', rungs: [] } } + case 'fbd': + return { + language, + value: { + name: 'main', + rung: { + comment: '', + edges: [], + nodes: [], + }, + }, + } + default: + return { language, value: '' } + } + })(), + }, +}) + +const createProjectFile = (dataToCreateProjectFile: CreateProjectFileProps): PLCProject => ({ + meta: { + name: dataToCreateProjectFile.name, + type: dataToCreateProjectFile.type, + }, + data: { + pous: [], + dataTypes: [], + configuration: { + resource: { + tasks: [ + { + name: 'task0', + triggering: 'Cyclic', + interval: dataToCreateProjectFile.time, + priority: 1, + }, + ], + instances: [ + { + name: 'instance0', + program: 'main', + task: 'task0', + }, + ], + globalVariables: [], + }, + }, + }, +}) + +const createProjectDefaultStructure = ( + basePath: string, + dataToCreateProjectFile: CreateProjectFileProps, +): CreateProjectDefaultDirectoriesResponse => { + const content: { + project: PLCProject | null + pous: PLCPou[] + deviceConfiguration: DeviceConfiguration | null + devicePinMapping: DevicePin[] + } = { + project: null, + pous: [], + deviceConfiguration: null, + devicePinMapping: [], + } + + /** + * Create the default directories in the project structure + */ + + // Create all the default directories if they do not exist + const directories = projectDefaultDirectories + for (const directory of directories) { + const dirPath = `${basePath}/${directory}` + try { + if (!fileOrDirectoryExists(dirPath)) createDirectory(dirPath) + } catch (error) { + return { + success: false, + error: { + title: 'Error creating project directories', + description: `Failed to create directory at ${dirPath}`, + error: error, + }, + } + } + } + + /** + * Create the default files in the project structure + */ + + // Create the root files + // These are files that are not in a subdirectory, but directly in the project root + // For example: project.json + const rootFiles = Object.entries(projectDefaultFilesMapSchema).filter( + ([file]) => !file.includes('/') && file.includes('.'), + ) + for (const [file, _schema] of rootFiles) { + const filePath = basePath + + switch (file) { + case 'project.json': + content.project = createProjectFile(dataToCreateProjectFile) + try { + CreateJSONFile(filePath, JSON.stringify(content.project, null, 2), file.split('.')[0]) + } catch (error) { + return { + success: false, + error: { + title: 'Error creating project file', + description: `Failed to create project file at ${filePath}`, + error: error, + }, + } + } + break + default: + break + } + } + + // Create the directories and files that are in subdirectories + // For example: devices/configuration.json + const fileDirectories = Object.entries(projectDefaultFilesMapSchema).filter( + ([file]) => file.includes('/') && file.includes('.'), + ) + for (const [file, schema] of fileDirectories) { + const [directory, fileName] = file.split('/') + const filePath = `${basePath}/${directory}` + const defaultValue = getDefaultSchemaValues(schema) + + try { + switch (file) { + case 'devices/configuration.json': + content.deviceConfiguration = defaultValue as DeviceConfiguration + content.deviceConfiguration.communicationConfiguration.modbusRTU.rtuBaudRate = '115200' + try { + CreateJSONFile(filePath, JSON.stringify(content.deviceConfiguration, null, 2), fileName.split('.')[0]) + } catch (error) { + return { + success: false, + error: { + title: 'Error creating device configuration file', + description: `Failed to create device configuration file at ${filePath}`, + error: error, + }, + } + } + break + case 'devices/pin-mapping.json': + content.devicePinMapping = defaultValue as DevicePin[] + try { + CreateJSONFile(filePath, JSON.stringify(content.devicePinMapping, null, 2), fileName.split('.')[0]) + } catch (error) { + return { + success: false, + error: { + title: 'Error creating device pin mapping file', + description: `Failed to create device pin mapping file at ${filePath}`, + error: error, + }, + } + } + break + default: + break + } + } catch (error) { + return { + success: false, + error: { + title: 'Error creating project directories', + description: `Failed to create directory or file at ${filePath}`, + error: error, + }, + } + } + } + + const pou = definePou(dataToCreateProjectFile.language) + const pouPath = `${basePath}/pous/${pou.type}s` + + try { + if (!fileOrDirectoryExists(pouPath)) createDirectory(pouPath) + const flat = ipcPouToFlat(pou) + const extension: string = getExtensionFromLanguage(flat.body.language) + const textContent: string = serializePouToText(flat) + const filePath = `${pouPath}/${flat.name}${extension}` + writeFileSync(filePath, textContent, 'utf-8') + } catch (error) { + return { + success: false, + error: { + title: 'Error creating POU file', + description: `Failed to create POU file at ${pouPath}`, + error: error, + }, + } + } + + content.pous.push(pou) + + return { + success: true, + data: { + meta: { path: basePath }, + content: content as { + project: PLCProject + pous: PLCPou[] + deviceConfiguration: DeviceConfiguration + devicePinMapping: DevicePin[] + }, + }, + } +} + +export { createProjectDefaultStructure, createProjectFile } diff --git a/src/main/services/project-service/utils/index.ts b/src/backend/editor/services/project-service/utils/index.ts similarity index 100% rename from src/main/services/project-service/utils/index.ts rename to src/backend/editor/services/project-service/utils/index.ts diff --git a/src/backend/editor/services/project-service/utils/read-project.ts b/src/backend/editor/services/project-service/utils/read-project.ts new file mode 100644 index 000000000..ac9676d08 --- /dev/null +++ b/src/backend/editor/services/project-service/utils/read-project.ts @@ -0,0 +1,680 @@ +import { projectDefaultFilesMapSchema, projectPouDirectories } from '@root/types/IPC/project-service' +import { IProjectServiceReadFilesResponse } from '@root/types/IPC/project-service/read-project' +import { DeviceConfiguration, DevicePin } from '@root/types/PLC/devices' +import { + PLCPou, + PLCPouSchema, + PLCProject, + PLCRemoteDevice, + PLCRemoteDeviceSchema, + PLCServer, + PLCServerSchema, +} from '@root/types/PLC/open-plc' +import { getDefaultSchemaValues } from '@root/utils/default-zod-schema-values' +import { migrateProjectToNameTypeSystem, needsMigration } from '@root/utils/migrate-project-to-name-type-system' +import { getExtensionFromLanguage } from '@root/utils/PLC/pou-file-extensions' +import { + detectLanguageFromExtension, + parseGraphicalPouFromString, + parseHybridPouFromString, + parseTextualPouFromString, +} from '@root/utils/PLC/pou-text-parser' +import { serializePouToText } from '@root/utils/PLC/pou-text-serializer' +import { promises, readdirSync, readFileSync, writeFileSync } from 'fs' +import { basename, dirname, extname, join, sep } from 'path' +import { ZodTypeAny } from 'zod' + +import { createDirectory, fileOrDirectoryExists, ipcPouToFlat } from '../../../utils' + +/** + * Checks if the given directory is a valid project directory according to the expected structure. + * + * This function verifies that the directory exists, contains only allowed files and directories, + * and includes a required project file (`project.json`). It returns an object indicating whether + * the directory is valid, and provides error details if not. + * + * @param basePath - The absolute path to the directory to check. + * @returns An object with a `success` boolean and, if unsuccessful, an `error` object containing + * a title, description, and the underlying error. + * + * @remarks + * The validation logic in the for loop is a work in progress (WIP). In the future, only + * `projectDefaultFilesMapSchema` will be used for validation. + */ +function checkIfDirectoryIsAValidProjectDirectory(basePath: string): { + success: boolean + error?: { title: string; description: string; error: Error } +} { + // Check if the base path exists and is a directory + if (!fileOrDirectoryExists(basePath)) { + return { + success: false, + error: { + title: 'Directory not found', + description: 'The selected directory does not exist.', + error: new Error('Directory does not exist'), + }, + } + } + + const entries = readdirSync(basePath, { withFileTypes: true, recursive: false }) + let hasProjectFile = false + + for (const entry of entries) { + if (entry.isFile() && entry.name === 'project.json') { + hasProjectFile = true + break + } + } + + return { + success: hasProjectFile, + error: hasProjectFile + ? undefined + : { + title: 'Invalid project', + description: 'The selected directory is not a valid OpenPLC project.', + error: new Error('project.json not found in directory'), + }, + } +} + +function safeParseProjectFile>( + fileName: K, + data: unknown, + schema?: ZodTypeAny, +) { + if (!(fileName in projectDefaultFilesMapSchema) && !schema) { + throw new Error(`File ${fileName} is not a valid project file or schema is not provided.`) + } + + const fileSchema = projectDefaultFilesMapSchema[fileName] ?? schema + const result = fileSchema.safeParse(data) + + /** + * TODO: Handle the case where the file does not match the expected schema + * This could be due to a corrupted file or an unsupported version. + * For now, we throw an error, but in the future, we might want to + * handle this more gracefully, perhaps by returning a default value or + * logging a warning instead of throwing an error. + */ + if (!result.success) { + throw new Error(`Failed to parse ${fileName}: ${result.error.message}`) + } + + return result.data +} + +function readAndParseFile(filePath: string, fileName: string, schema: ZodTypeAny) { + let file: string | undefined + // File does not exist, create with default value from schema + if (!fileOrDirectoryExists(filePath)) { + const dir = dirname(filePath) + + // Ensure the directory exists + if (!fileOrDirectoryExists(dir)) { + createDirectory(dir) + } + + // Create the file with default values from the schema + const defaultValue = getDefaultSchemaValues(schema) + writeFileSync(filePath, JSON.stringify(defaultValue, null, 2), 'utf-8') + file = JSON.stringify(defaultValue) + } + + // File exists, read and parse + else { + file = readFileSync(filePath, 'utf-8') + + // If the file is empty, create it with default values + if (!file) { + const defaultValue = getDefaultSchemaValues(schema) + writeFileSync(filePath, JSON.stringify(defaultValue, null, 2), 'utf-8') + file = JSON.stringify(defaultValue) + } + } + return safeParseProjectFile(fileName as keyof typeof projectDefaultFilesMapSchema, JSON.parse(file), schema) +} + +/** + * Valid POU file extensions (text-based and JSON for backward compatibility) + */ +const VALID_POU_EXTENSIONS = ['.st', '.il', '.ld', '.fbd', '.py', '.cpp', '.json'] + +/** + * Helper function to detect POU type from the file path + * @param filePath - The file path containing the POU type directory + * @returns The POU type (program, function, function-block) + * @throws Error if POU type cannot be determined + */ +function detectPouTypeFromPath(filePath: string): string { + const normalizedPath = filePath.split(sep).join('/') + + if (normalizedPath.includes('/programs/')) { + return 'program' + } else if (normalizedPath.includes('/function-blocks/')) { + return 'function-block' + } else if (normalizedPath.includes('/functions/')) { + return 'function' + } + throw new Error(`Cannot determine POU type from path: ${filePath}`) +} + +/** + * Helper function to find the last END_VAR in the content + * @param content - The content to search + * @param startIndex - The index to start searching from + * @returns The index after the last END_VAR, or -1 if not found + */ +function findLastEndVarIndex(content: string, startIndex: number): number { + let lastEndVarIndex = -1 + const regex = /\bEND_VAR\b/gi + regex.lastIndex = startIndex + + let match: RegExpExecArray | null + while ((match = regex.exec(content)) !== null) { + lastEndVarIndex = match.index + match[0].length + } + + return lastEndVarIndex +} + +/** + * Fallback extraction when parsing fails - extracts raw variables block and body + * @param content - The file content + * @param language - The language code + * @param pouType - The POU type + * @param pouName - The POU name (from filename) + * @returns A partial PLCPou with empty variables array but preserved variablesText + */ +function createFallbackPou(content: string, language: string, pouType: string, pouName: string): PLCPou { + const docMatch = content.match(/^\s*\(\*\s*(.*?)\s*\*\)\s*\n/s) + const documentation = docMatch ? docMatch[1].trim() : '' + const remainingContent = docMatch ? content.slice(docMatch[0].length) : content + + const varStartIndex = remainingContent.search( + /\b(VAR_INPUT|VAR_OUTPUT|VAR_IN_OUT|VAR_EXTERNAL|VAR_TEMP|VAR_GLOBAL|VAR)\b/i, + ) + + let variablesText = 'VAR\nEND_VAR' // Default if no variables section found + let bodyStartIndex = 0 + + const pouTypeKeywords = { + program: 'PROGRAM', + function: 'FUNCTION', + 'function-block': 'FUNCTION_BLOCK', + } + const typeKeyword = pouTypeKeywords[pouType as keyof typeof pouTypeKeywords] + const declarationRegex = new RegExp(`^\\s*(${typeKeyword})\\s+(\\w+)(?:\\s*:\\s*(\\w+))?`, 'i') + const declarationMatch = remainingContent.match(declarationRegex) + + if (declarationMatch) { + bodyStartIndex = declarationMatch[0].length + } + + if (varStartIndex !== -1) { + const varSectionStart = varStartIndex + const lastEndVarIndex = findLastEndVarIndex(remainingContent, varSectionStart) + + if (lastEndVarIndex !== -1) { + variablesText = remainingContent.slice(varSectionStart, lastEndVarIndex) + bodyStartIndex = lastEndVarIndex + } + } + + let bodyValue: unknown + + if (language === 'st' || language === 'il' || language === 'python' || language === 'cpp') { + const endKeywords = { + program: 'END_PROGRAM', + function: 'END_FUNCTION', + 'function-block': 'END_FUNCTION_BLOCK', + } + const endKeyword = endKeywords[pouType as keyof typeof endKeywords] + const endKeywordRegex = new RegExp(`\\b${endKeyword}\\b`, 'i') + const endMatch = remainingContent.slice(bodyStartIndex).search(endKeywordRegex) + + if (endMatch !== -1) { + bodyValue = remainingContent.slice(bodyStartIndex, bodyStartIndex + endMatch).trim() + } else { + bodyValue = remainingContent.slice(bodyStartIndex).trim() + } + } else if (language === 'ld' || language === 'fbd') { + const endKeywords = { + program: 'END_PROGRAM', + function: 'END_FUNCTION', + 'function-block': 'END_FUNCTION_BLOCK', + } + const endKeyword = endKeywords[pouType as keyof typeof endKeywords] + const endKeywordRegex = new RegExp(`\\b${endKeyword}\\b`, 'i') + const endMatch = remainingContent.slice(bodyStartIndex).search(endKeywordRegex) + + const bodyContent = + endMatch !== -1 + ? remainingContent.slice(bodyStartIndex, bodyStartIndex + endMatch).trim() + : remainingContent.slice(bodyStartIndex).trim() + + try { + bodyValue = JSON.parse(bodyContent) + } catch { + bodyValue = { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + } + } + } else { + bodyValue = '' + } + + const commonData = { + name: pouName, + variables: [], + documentation, + variablesText, + } + + if (pouType === 'function') { + return { + type: 'function', + data: { + ...commonData, + language: language as 'st' | 'il' | 'python' | 'cpp' | 'ld' | 'fbd', + returnType: 'BOOL', + body: { + language: language as 'st' | 'il' | 'python' | 'cpp' | 'ld' | 'fbd', + value: bodyValue, + }, + }, + } as PLCPou + } else if (pouType === 'function-block') { + return { + type: 'function-block', + data: { + ...commonData, + language: language as 'st' | 'il' | 'python' | 'cpp' | 'ld' | 'fbd', + body: { + language: language as 'st' | 'il' | 'python' | 'cpp' | 'ld' | 'fbd', + value: bodyValue, + }, + }, + } as PLCPou + } else { + return { + type: 'program', + data: { + ...commonData, + language: language as 'st' | 'il' | 'python' | 'cpp' | 'ld' | 'fbd', + body: { + language: language as 'st' | 'il' | 'python' | 'cpp' | 'ld' | 'fbd', + value: bodyValue, + }, + }, + } as PLCPou + } +} + +/** + * Parse a POU file (either JSON or text-based format) + * @param filePath - The path to the POU file + * @param fileName - The name of the file (for error messages) + * @returns Parsed PLCPou object + * @throws Error if parsing fails + */ +function readAndParsePouFile(filePath: string, fileName: string): PLCPou { + const fileExtension = extname(filePath) + const fileContent = readFileSync(filePath, 'utf-8') + + if (fileExtension === '.json') { + const parsedJson = JSON.parse(fileContent) + const result = PLCPouSchema.safeParse(parsedJson) + if (!result.success) { + throw new Error(`Failed to parse JSON POU file ${fileName}: ${result.error.message}`) + } + return result.data + } + + try { + const pouType = detectPouTypeFromPath(filePath) + const language = detectLanguageFromExtension(filePath) + + let pou: PLCPou + + if (language === 'st' || language === 'il') { + pou = parseTextualPouFromString(fileContent, language, pouType) + } else if (language === 'python' || language === 'cpp') { + pou = parseHybridPouFromString(fileContent, language, pouType) + } else if (language === 'ld' || language === 'fbd') { + pou = parseGraphicalPouFromString(fileContent, language, pouType) + } else { + throw new Error(`Unsupported language: ${language}`) + } + + // The text parser returns the port format ({name, pouType, interface, body}) + // but the rest of the main process pipeline expects the old discriminated-union + // format ({type, data: {name, variables, body, documentation}}). + // Convert to old format for compatibility. + const portPou = pou as unknown as { + name: string + pouType: string + interface?: { returnType?: string; variables: unknown[] } + body: { language: string; value: unknown } + documentation?: string + } + const oldFormatPou = { + type: portPou.pouType, + data: { + language: language as 'st' | 'il' | 'ld' | 'fbd' | 'python' | 'cpp', + name: portPou.name, + variables: portPou.interface?.variables ?? [], + ...(portPou.pouType === 'function' ? { returnType: portPou.interface?.returnType ?? '' } : {}), + body: portPou.body, + documentation: portPou.documentation ?? '', + }, + } as PLCPou + + return oldFormatPou + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to parse POU file ${fileName}: ${error.message}`) + } + throw new Error(`Failed to parse POU file ${fileName}: Unknown error`) + } +} + +/** + * This function reads a directory recursively and parses POU files according to their format. + * Supports both text-based formats (.st, .il, .ld, .fbd, .py, .cpp) and JSON format (backward compatibility). + * When both text-based and JSON files exist for the same POU, the text-based file is preferred. + */ +function readDirectoryRecursive( + baseDir: string, + baseFileName: string, + projectFiles: Record, + pouNameMap: Map, +) { + const entries = readdirSync(baseDir, { withFileTypes: true }) + + for (const entry of entries) { + const entryPath = join(baseDir, entry.name) + const entryKey = join(baseFileName, entry.name) + + if (entry.isFile()) { + const fileExtension = extname(entry.name) + + if (!VALID_POU_EXTENSIONS.includes(fileExtension)) { + continue + } + + const pouName = basename(entry.name, fileExtension) + const isTextBased = fileExtension !== '.json' + const existingEntry = pouNameMap.get(pouName) + + if (existingEntry) { + if (isTextBased && !existingEntry.isTextBased) { + delete projectFiles[existingEntry.key] + try { + projectFiles[entryKey] = readAndParsePouFile(entryPath, entryKey) + pouNameMap.set(pouName, { key: entryKey, isTextBased }) + } catch { + try { + const fileContent = readFileSync(entryPath, 'utf-8') + const pouType = detectPouTypeFromPath(entryPath) + const language: string = detectLanguageFromExtension(entryPath) + const fallbackPou = createFallbackPou(fileContent, language, pouType, pouName) + projectFiles[entryKey] = fallbackPou + pouNameMap.set(pouName, { key: entryKey, isTextBased }) + } catch { + // Intentionally skip POUs that cannot be parsed and also fail fallback creation + } + } + } + } else { + try { + projectFiles[entryKey] = readAndParsePouFile(entryPath, entryKey) + pouNameMap.set(pouName, { key: entryKey, isTextBased }) + } catch { + try { + const fileContent = readFileSync(entryPath, 'utf-8') + const pouType = detectPouTypeFromPath(entryPath) + const language: string = detectLanguageFromExtension(entryPath) + const fallbackPou = createFallbackPou(fileContent, language, pouType, pouName) + projectFiles[entryKey] = fallbackPou + pouNameMap.set(pouName, { key: entryKey, isTextBased }) + } catch { + // Intentionally skip POUs that cannot be parsed and also fail fallback creation + } + } + } + } else if (entry.isDirectory()) { + readDirectoryRecursive(entryPath, entryKey, projectFiles, pouNameMap) + } + } +} + +export async function readProjectFiles(basePath: string): Promise { + const isValidProjectDirectory = checkIfDirectoryIsAValidProjectDirectory(basePath) + if (!isValidProjectDirectory.success) { + return isValidProjectDirectory + } + + const projectFiles: Record = {} + const pouFiles: Record = {} + + /** + * Read the default project files from the project directory. + * This includes the project.json, devices/configuration.json, and devices/pin-mapping.json files. + * If any of these files do not exist, they will be created with default values from the schema. + * If any of the files cannot be read or parsed, use default values and log a warning. + */ + for (const fileName of Object.keys(projectDefaultFilesMapSchema) as (keyof typeof projectDefaultFilesMapSchema)[]) { + const schema = projectDefaultFilesMapSchema[fileName] + const filePath = join(basePath, fileName) + try { + projectFiles[fileName] = readAndParseFile(filePath, fileName, schema) + } catch { + projectFiles[fileName] = getDefaultSchemaValues(schema) + } + } + + /** + * Read pou files from the project directory. + * Use a map to track POUs by name and prefer text-based files over JSON. + */ + const pouNameMap = new Map() + for (const pouDirectory of projectPouDirectories) { + const pouDirPath = join(basePath, pouDirectory) + if (fileOrDirectoryExists(pouDirPath)) { + readDirectoryRecursive(pouDirPath, pouDirectory, pouFiles, pouNameMap) + } + } + + if (projectFiles['project.json']) { + const project = projectFiles['project.json'] as PLCProject + if (project.data.pous && project.data.pous.length > 0) { + const migrationResults = await Promise.allSettled( + project.data.pous.map(async (pou) => { + const flat = ipcPouToFlat(pou) + const pouType = pou.type.toLowerCase() + 's' + const extension: string = getExtensionFromLanguage(flat.body.language) + const pouFilePath = join(basePath, 'pous', pouType, `${flat.name}${extension}`) + + try { + if (!fileOrDirectoryExists(pouFilePath)) { + await promises.mkdir(dirname(pouFilePath), { recursive: true }) + const textContent: string = serializePouToText(flat) + await promises.writeFile(pouFilePath, textContent, 'utf-8') + } + pouFiles[join('pous', pouType, `${flat.name}${extension}`)] = pou + return { success: true, pouName: flat.name } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return { success: false, pouName: pou.data.name, error: errorMessage } + } + }), + ) + + const successfulMigrations = migrationResults.filter((r) => r.status === 'fulfilled' && r.value.success) + + if (successfulMigrations.length === project.data.pous.length) { + try { + project.data.pous = [] + await promises.writeFile(join(basePath, 'project.json'), JSON.stringify(project, null, 2), 'utf-8') + } catch { + // Intentionally continue if clearing POUs array fails to allow partial migration + } + } + } + } + + /** + * Ensure POU names are set correctly. + * For text-based formats, the name is already extracted from the file content by the parser. + * For JSON files (backward compatibility), extract from filename if not already set. + */ + Object.keys(pouFiles).forEach((key) => { + const pouFile = pouFiles[key] as PLCPou + if (!pouFile.data.name) { + const fileExtension = extname(key) + const pouName = basename(key, fileExtension) + if (pouName) { + pouFile.data.name = pouName + pouFiles[key] = pouFile + } + } + }) + + /** + * Read server config files from the devices/servers directory. + * Each server is stored as an individual JSON file. + */ + const serverFiles: PLCServer[] = [] + const serversDir = join(basePath, 'devices', 'servers') + if (fileOrDirectoryExists(serversDir)) { + const serverEntries = readdirSync(serversDir, { withFileTypes: true }) + for (const entry of serverEntries) { + if (entry.isFile() && extname(entry.name) === '.json') { + const serverFilePath = join(serversDir, entry.name) + try { + const serverContent = readFileSync(serverFilePath, 'utf-8') + const parsedServer = JSON.parse(serverContent) + const result = PLCServerSchema.safeParse(parsedServer) + if (result.success) { + serverFiles.push(result.data) + } + } catch { + // Skip invalid server files + } + } + } + } + + /** + * Read remote device config files from the devices/remote directory. + * Each remote device is stored as an individual JSON file. + */ + const remoteDeviceFiles: PLCRemoteDevice[] = [] + const remoteDevicesDir = join(basePath, 'devices', 'remote') + if (fileOrDirectoryExists(remoteDevicesDir)) { + const remoteDeviceEntries = readdirSync(remoteDevicesDir, { withFileTypes: true }) + for (const entry of remoteDeviceEntries) { + if (entry.isFile() && extname(entry.name) === '.json') { + const remoteDeviceFilePath = join(remoteDevicesDir, entry.name) + try { + const remoteDeviceContent = readFileSync(remoteDeviceFilePath, 'utf-8') + const parsedRemoteDevice = JSON.parse(remoteDeviceContent) + const result = PLCRemoteDeviceSchema.safeParse(parsedRemoteDevice) + if (result.success) { + remoteDeviceFiles.push(result.data) + } + } catch { + // Skip invalid remote device files + } + } + } + } + + const returnData: IProjectServiceReadFilesResponse['data'] = { + project: projectFiles['project.json'] as PLCProject, + pous: Object.values(pouFiles).map((pou) => pou as PLCPou), + deviceConfiguration: projectFiles['devices/configuration.json'] as DeviceConfiguration, + devicePinMapping: projectFiles['devices/pin-mapping.json'] as DevicePin[], + servers: serverFiles, + remoteDevices: remoteDeviceFiles, + } + + // Check if project needs migration from ID-based to name+type-based system + if (needsMigration(returnData.project.data)) { + console.log('Project needs migration from ID-based to name+type-based system') + const { migratedProject, report } = migrateProjectToNameTypeSystem(returnData.project.data) + + if (report.success) { + console.log(`Migration successful: ${report.variablesMigrated} variables migrated`) + + returnData.project.data = migratedProject + + returnData.pous = returnData.pous.map((pou) => { + const migratedPou = migratedProject.pous.find((p) => p.data.name === pou.data.name) + if (migratedPou) { + return { ...pou, data: migratedPou.data } as PLCPou + } + return pou + }) + + // Create a backup of the original project before saving migrated version + const backupPath = join(basePath, 'project.backup.json') + if (!fileOrDirectoryExists(backupPath)) { + try { + await promises.writeFile(backupPath, JSON.stringify(projectFiles['project.json'], null, 2), 'utf-8') + console.log(`Backup created at: ${backupPath}`) + } catch (error) { + console.error('Failed to create backup:', error) + return { + success: false, + message: 'Failed to create backup before migration', + error: { + title: 'Backup Creation Failed', + description: + 'Could not create a backup of the original project before migration. Migration aborted to prevent data loss.', + error: error, + }, + } + } + } + + try { + await promises.writeFile(join(basePath, 'project.json'), JSON.stringify(returnData.project, null, 2), 'utf-8') + console.log('Migrated project saved successfully') + + for (const pou of returnData.pous) { + const pouType = pou.type.toLowerCase() + 's' + const pouFilePath = join(basePath, 'pous', pouType, `${pou.data.name}.json`) + await promises.writeFile(pouFilePath, JSON.stringify(pou, null, 2), 'utf-8') + } + console.log('Migrated POUs saved successfully') + } catch (error) { + console.error('Failed to save migrated project:', error) + return { + success: false, + message: 'Failed to save migrated project', + error: { + title: 'Migration Save Failed', + description: 'Migration completed but failed to save the migrated project files.', + error: error, + }, + } + } + } else { + console.error('Migration failed:', report.errors) + if (report.unresolvedReferences.length > 0) { + console.error('Unresolved references:', report.unresolvedReferences) + } + } + } + + return { + success: true, + message: 'Project files read successfully', + data: returnData, + } +} diff --git a/src/main/services/user-service/data/arduino.ts b/src/backend/editor/services/user-service/data/arduino.ts similarity index 100% rename from src/main/services/user-service/data/arduino.ts rename to src/backend/editor/services/user-service/data/arduino.ts diff --git a/src/main/services/user-service/data/history.ts b/src/backend/editor/services/user-service/data/history.ts similarity index 100% rename from src/main/services/user-service/data/history.ts rename to src/backend/editor/services/user-service/data/history.ts diff --git a/src/main/services/user-service/data/settings.ts b/src/backend/editor/services/user-service/data/settings.ts similarity index 100% rename from src/main/services/user-service/data/settings.ts rename to src/backend/editor/services/user-service/data/settings.ts diff --git a/src/backend/editor/services/user-service/index.ts b/src/backend/editor/services/user-service/index.ts new file mode 100644 index 000000000..17fcf74f4 --- /dev/null +++ b/src/backend/editor/services/user-service/index.ts @@ -0,0 +1,255 @@ +import { getErrorMessage } from '@root/utils/get-error-message' +import { exec } from 'child_process' +import { app } from 'electron' +import { access, constants, mkdir, rename, rm, writeFile } from 'fs/promises' +import { basename, join } from 'path' +import { promisify } from 'util' + +import { ARDUINO_DATA } from './data/arduino' +import { HISTORY_DATA } from './data/history' +import { SETTINGS_DATA } from './data/settings' +import type { ArduinoListOutput } from './user-service-types' + +/** + * UserService class responsible for user settings and history management. + * This class is a singleton and should be instantiated only once during the application lifecycle. + * For now it is used as a static class, although it isn't recommended to use static classes in TypeScript. + * This approach is taken to avoid the need for a singleton instance and to leave room for future changes in the class structure. + */ +class UserService { + constructor() { + void this.#initializeUserSettingsAndHistory() + } + + /** + * Static methods and properties. + */ + + static DEFAULT_SETTINGS = SETTINGS_DATA + + static DEFAULT_HISTORY = HISTORY_DATA + + static ARDUINO_FILE_CONTENT = ARDUINO_DATA + + static async createDirectoryIfNotExists(path: string): Promise { + /** + * The access() method checks the existence of the file or directory at the specified path. + * If the file or directory exists, the method resolves successfully. + */ + try { + await access(path, constants.F_OK) + } catch { + try { + await mkdir(path, { recursive: true }) + } catch (err) { + // If the error is due to the directory already existing, log a warning and continue. + if (err instanceof Error && err.message.includes('EEXIST')) { + console.warn(`Directory already exists at ${path}.\nSkipping creation.`) + } else if (err instanceof Error) { + console.error(`Error creating directory at ${path}: ${getErrorMessage(err)}`) + } else { + console.error(`Error creating directory at ${path}: ${getErrorMessage(err)}`) + } + } + } + } + + static async createJSONFileIfNotExists(filePath: string, data: object): Promise { + try { + await writeFile(filePath, JSON.stringify(data, null, 2), { flag: 'wx' }) + } catch (err) { + // If the error is due to the file already existing, log a warning and continue. + if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') { + console.warn(`File already exists at ${filePath}.\nSkipping creation.`) + return + } else if (err instanceof Error) { + console.error(`Error creating file at ${filePath}: ${getErrorMessage(err)}`) + throw new Error(`Failed to create file at ${filePath}: ${getErrorMessage(err)}`) + } else { + console.error(`Error creating file at ${filePath}: ${getErrorMessage(err)}`) + throw new Error(`Failed to create file at ${filePath}: ${getErrorMessage(err)}`) + } + } + } + + static async deleteFile(filePath: string): Promise { + try { + await rm(filePath, { recursive: true, force: true }) + } catch (err) { + console.error(`Error deleting file at ${filePath}: ${getErrorMessage(err)}`) + throw new Error(`Failed to delete file at ${filePath}: ${getErrorMessage(err)}`) + } + } + + static async renameFile( + oldFilePath: string, + newFilePath: string, + ): Promise<{ + success: boolean + error?: { + title: string + description: string + error: Error + } + data?: { filePath: string } + }> { + const newFileName = basename(newFilePath) + try { + await rename(oldFilePath, newFilePath) + return { success: true, data: { filePath: newFilePath } } + } catch (err) { + console.error(`Error renaming file at ${oldFilePath} to ${newFileName}: ${getErrorMessage(err)}`) + return { + success: false, + error: { title: 'File Rename Error', description: 'Failed to rename file', error: err as Error }, + } + } + } + + /** + * ----------------------------------------------------------------------- + */ + + /** + * Checks if the user base settings folder exists and creates it if it doesn't. + * Also creates a user settings file with default values if it doesn't exist. + * + * @returns {Promise} Resolves when the user base settings folder and file are ready. + */ + async #checkIfUserBaseSettingsExists(): Promise { + const pathToUserDataFolder = join(app.getPath('userData'), 'User') + const pathToUserDataFile = join(pathToUserDataFolder, 'settings.json') + + await UserService.createDirectoryIfNotExists(pathToUserDataFolder) + await UserService.createJSONFileIfNotExists(pathToUserDataFile, UserService.DEFAULT_SETTINGS) + } + + async #checkIfLogFolderExists(): Promise { + const pathToLogFolder = join(app.getPath('userData'), 'logs') + await UserService.createDirectoryIfNotExists(pathToLogFolder) + } + + /** + * Checks if the user history folder exists and creates it if it doesn't. + * Also creates a user history file with default values if it doesn't exist. + * + * @returns {Promise} Resolves when the user history folder and file are ready. + */ + async #checkIfUserHistoryFolderExists(): Promise { + const pathToUserHistoryFolder = join(app.getPath('userData'), 'User', 'History') + const pathToUserProjectInfoFile = join(pathToUserHistoryFolder, 'projects.json') + const pathToUserLibraryInfoFile = join(pathToUserHistoryFolder, 'libraries.json') + + await UserService.createDirectoryIfNotExists(pathToUserHistoryFolder) + await UserService.createJSONFileIfNotExists(pathToUserProjectInfoFile, UserService.DEFAULT_HISTORY.projects) + await UserService.createJSONFileIfNotExists(pathToUserLibraryInfoFile, UserService.DEFAULT_HISTORY.libraries) + } + + /** + * Checks if the Arduino CLI configuration file exists and creates it if it doesn't. + */ + async #checkIfArduinoCliConfigExists(): Promise { + const pathToArduinoCliConfig = join(app.getPath('userData'), 'User', 'arduino-cli.yaml') + try { + await writeFile(pathToArduinoCliConfig, UserService.ARDUINO_FILE_CONTENT, { flag: 'wx' }) + } catch (err) { + // If the error is due to the file already existing, log a warning and continue. + if (err instanceof Error && err.message.includes('EEXIST')) { + console.warn(`File already exists at ${pathToArduinoCliConfig}.\nSkipping creation.`) + } else if (err instanceof Error) { + console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + } else { + console.error(`Error creating Arduino CLI config at ${pathToArduinoCliConfig}: ${getErrorMessage(err)}`) + } + } + } + + async #executeArduinoCliCommand(command: string): Promise<{ stderr: string; stdout: string }> { + const developmentMode = process.env.NODE_ENV === 'development' + const executeCommand = promisify(exec) + + const platformSpecificBinaryPath = join(process.platform, process.arch) + + let binaryPath = join( + developmentMode ? process.cwd() : process.resourcesPath, + developmentMode ? 'resources' : '', + 'bin', + developmentMode ? platformSpecificBinaryPath : '', + 'arduino-cli', + ) + + if (process.platform === 'win32') { + binaryPath = `${binaryPath}.exe` + } + + return executeCommand(`"${binaryPath}" ${command}`) + } + + /** + * Checks if the Core List file exists and creates it if it doesn't. + * TODO: This function must be refactored. + * - Must validate if this implementation for the core list file is correct. + */ + + async #checkIfArduinoCoreControlFileExists(): Promise { + const pathToRuntimeFolder = join(app.getPath('userData'), 'User', 'Runtime') + const pathToArduinoCoreControlFile = join(pathToRuntimeFolder, 'arduino-core-control.json') + + const { stderr, stdout } = await this.#executeArduinoCliCommand('core list --json') + if (stderr) { + console.error(`Error listing cores: ${String(stderr)}`) + return + } + + const coreListOutput = JSON.parse(stdout) as ArduinoListOutput['core'] + + const installedCoresFromListOutput = coreListOutput.platforms.map((core) => ({ + [core.id]: core.installed_version, + })) + + await UserService.createDirectoryIfNotExists(pathToRuntimeFolder) + await writeFile(pathToArduinoCoreControlFile, JSON.stringify(installedCoresFromListOutput, null, 2), { flag: 'w' }) + + // This is a legacy file that is no longer used, should be removed in the next major release!!! + const pathToLegacyHals = join(pathToRuntimeFolder, 'hals.json') + await rm(pathToLegacyHals, { recursive: true, force: true }) + } + + async #checkIfArduinoLibraryControlFileExists() { + const pathToRuntimeFolder = join(app.getPath('userData'), 'User', 'Runtime') + const pathToArduinoLibraryControlFile = join(pathToRuntimeFolder, 'arduino-library-control.json') + + const { stderr, stdout } = await this.#executeArduinoCliCommand('lib list --json') + if (stderr) { + console.error(`Error listing libraries: ${String(stderr)}`) + return + } + + const libraryListOutput = JSON.parse(stdout) as ArduinoListOutput['library'] + + const installedLibrariesFromListOutput = libraryListOutput.installed_libraries.map(({ library }) => ({ + [library.name]: library.version, + })) + + await UserService.createDirectoryIfNotExists(pathToRuntimeFolder) + await writeFile(pathToArduinoLibraryControlFile, JSON.stringify(installedLibrariesFromListOutput, null, 2), { + flag: 'w', + }) + } + /** + * Initializes user settings and history by checking the relevant folders and files. + * This method should be called during the application startup process. + * + * @returns {Promise} Resolves when all setup checks are complete. + */ + async #initializeUserSettingsAndHistory(): Promise { + await this.#checkIfUserBaseSettingsExists() + await this.#checkIfLogFolderExists() + await this.#checkIfUserHistoryFolderExists() + await this.#checkIfArduinoCliConfigExists() + await this.#checkIfArduinoCoreControlFileExists() + await this.#checkIfArduinoLibraryControlFileExists() + } +} + +export { UserService } diff --git a/src/main/services/user-service/user-service-types.ts b/src/backend/editor/services/user-service/user-service-types.ts similarity index 100% rename from src/main/services/user-service/user-service-types.ts rename to src/backend/editor/services/user-service/user-service-types.ts diff --git a/src/main/utils/create-directory.ts b/src/backend/editor/utils/create-directory.ts similarity index 100% rename from src/main/utils/create-directory.ts rename to src/backend/editor/utils/create-directory.ts diff --git a/src/main/utils/file-or-directory-exists.ts b/src/backend/editor/utils/file-or-directory-exists.ts similarity index 100% rename from src/main/utils/file-or-directory-exists.ts rename to src/backend/editor/utils/file-or-directory-exists.ts diff --git a/src/backend/editor/utils/index.ts b/src/backend/editor/utils/index.ts new file mode 100644 index 000000000..52f6ab326 --- /dev/null +++ b/src/backend/editor/utils/index.ts @@ -0,0 +1,7 @@ +export * from './create-directory' +export * from './file-or-directory-exists' +export * from './ipc-pou-to-flat' +export * from './json-manager' +export * from './path-picker' +export * from './resolve-html-path' +export * from './xml-manager' diff --git a/src/backend/editor/utils/ipc-pou-to-flat.ts b/src/backend/editor/utils/ipc-pou-to-flat.ts new file mode 100644 index 000000000..dc02067ad --- /dev/null +++ b/src/backend/editor/utils/ipc-pou-to-flat.ts @@ -0,0 +1,20 @@ +import type { PLCPou as FlatPou } from '@root/middleware/shared/ports/types' +import type { PLCPou as IpcPou } from '@root/types/PLC/open-plc' + +/** + * Convert the editor backend's nested IPC POU format to the flat port POU + * format expected by shared utilities (e.g. serializePouToText). + */ +export function ipcPouToFlat(pou: IpcPou): FlatPou & { variablesText?: string } { + return { + name: pou.data.name, + pouType: pou.type as FlatPou['pouType'], + interface: { + returnType: 'returnType' in pou.data ? (pou.data as { returnType?: string }).returnType : undefined, + variables: pou.data.variables as FlatPou['interface']['variables'], + }, + body: pou.data.body as FlatPou['body'], + documentation: pou.data.documentation, + variablesText: 'variablesText' in pou.data ? pou.data.variablesText : undefined, + } +} diff --git a/src/main/utils/is-empty-dir.ts b/src/backend/editor/utils/is-empty-dir.ts similarity index 100% rename from src/main/utils/is-empty-dir.ts rename to src/backend/editor/utils/is-empty-dir.ts diff --git a/src/main/utils/json-manager.ts b/src/backend/editor/utils/json-manager.ts similarity index 100% rename from src/main/utils/json-manager.ts rename to src/backend/editor/utils/json-manager.ts diff --git a/src/backend/editor/utils/path-picker.ts b/src/backend/editor/utils/path-picker.ts new file mode 100644 index 000000000..0222c88b8 --- /dev/null +++ b/src/backend/editor/utils/path-picker.ts @@ -0,0 +1,40 @@ +import { BrowserWindow, dialog } from 'electron' + +import { isEmptyDir } from './is-empty-dir' + +type GetProjectPathProps = InstanceType + +const getProjectPath = async (serviceManager: GetProjectPathProps) => { + const { canceled, filePaths } = await dialog.showOpenDialog(serviceManager, { + title: 'Choose an empty directory for new project', + properties: ['openDirectory', 'createDirectory'], + }) + if (canceled) { + return { + success: false, + error: { + title: 'Operation canceled', + description: 'Operation canceled by the user.', + }, + } + } + + const [filePath] = filePaths + + if (!(await isEmptyDir(filePath))) { + return { + success: false, + error: { + title: 'Directory is not empty', + description: 'The selected directory is not empty. Please choose an empty directory for a new project.', + }, + } + } + + return { + success: true, + path: filePath, + } +} + +export { getProjectPath } diff --git a/src/main/utils/resolve-html-path.ts b/src/backend/editor/utils/resolve-html-path.ts similarity index 100% rename from src/main/utils/resolve-html-path.ts rename to src/backend/editor/utils/resolve-html-path.ts diff --git a/src/utils/runtime-https-config.ts b/src/backend/editor/utils/runtime-https-config.ts similarity index 100% rename from src/utils/runtime-https-config.ts rename to src/backend/editor/utils/runtime-https-config.ts diff --git a/src/main/utils/xml-manager.ts b/src/backend/editor/utils/xml-manager.ts similarity index 100% rename from src/main/utils/xml-manager.ts rename to src/backend/editor/utils/xml-manager.ts diff --git a/src/backend/editor/websocket/websocket-debug-client.ts b/src/backend/editor/websocket/websocket-debug-client.ts new file mode 100644 index 000000000..a808d4f59 --- /dev/null +++ b/src/backend/editor/websocket/websocket-debug-client.ts @@ -0,0 +1,363 @@ +import { getErrorMessage } from '@root/utils/get-error-message' +import { io, Socket } from 'socket.io-client' + +import { ModbusDebugResponse, ModbusFunctionCode } from '../modbus/modbus-client' + +interface WebSocketDebugClientOptions { + host: string + port: number + token: string + rejectUnauthorized?: boolean +} + +export class WebSocketDebugClient { + private host: string + private port: number + private token: string + private socket: Socket | null = null + private rejectUnauthorized: boolean + + constructor(options: WebSocketDebugClientOptions) { + this.host = options.host + this.port = options.port + this.token = options.token + this.rejectUnauthorized = options.rejectUnauthorized ?? false + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + const url = `https://${this.host}:${this.port}/api/debug` + + this.socket = io(url, { + transports: ['websocket'], + auth: { + token: this.token, + }, + rejectUnauthorized: this.rejectUnauthorized, + reconnection: false, + timeout: 5000, + }) + + const timeoutHandle = setTimeout(() => { + this.socket?.disconnect() + reject(new Error('Connection timeout')) + }, 5000) + + this.socket.on('connect_error', (error: Error) => { + clearTimeout(timeoutHandle) + reject(error) + }) + + this.socket.io.on('error', (error: Error) => { + clearTimeout(timeoutHandle) + reject(error) + }) + + this.socket.on('connected', (data: { status: string }) => { + clearTimeout(timeoutHandle) + if (data.status === 'ok') { + resolve() + } else { + reject(new Error('Connection failed: invalid status')) + } + }) + }) + } + + disconnect(): void { + if (this.socket) { + this.socket.disconnect() + this.socket = null + } + } + + private bufferToHexString(buffer: Buffer): string { + return Array.from(buffer) + .map((byte) => byte.toString(16).toUpperCase().padStart(2, '0')) + .join(' ') + } + + private hexStringToBuffer(hexString: string): Buffer { + const bytes = hexString.split(' ').map((byte) => parseInt(byte, 16)) + return Buffer.from(bytes) + } + + async getMd5Hash(): Promise { + if (!this.socket) { + throw new Error('Not connected to target') + } + + const functionCode = ModbusFunctionCode.DEBUG_GET_MD5 + const endiannessCheck = 0xdead + + const request = Buffer.alloc(5) + request.writeUInt8(functionCode, 0) + request.writeUInt16BE(endiannessCheck, 1) + request.writeUInt8(0, 3) + request.writeUInt8(0, 4) + + const commandHex = this.bufferToHexString(request) + + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + reject(new Error('Request timeout')) + }, 5000) + + const responseHandler = (response: { success: boolean; data?: string; error?: string }) => { + clearTimeout(timeoutHandle) + this.socket?.off('debug_response', responseHandler) + + if (!response.success) { + reject(new Error(response.error || 'Unknown error')) + return + } + + if (!response.data) { + reject(new Error('No data in response')) + return + } + + try { + const responseBuffer = this.hexStringToBuffer(response.data) + + if (responseBuffer.length < 2) { + reject(new Error('Invalid response: too short')) + return + } + + const responseFunctionCode = responseBuffer.readUInt8(0) + const statusCode = responseBuffer.readUInt8(1) + + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { + reject(new Error('Function code mismatch')) + return + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + reject(new Error(`Target returned error code: 0x${statusCode.toString(16)}`)) + return + } + + const md5String = responseBuffer.slice(2).toString('utf-8').trim() + resolve(md5String) + } catch (error) { + reject(error instanceof Error ? error : new Error(getErrorMessage(error) as string)) + } + } + + this.socket!.on('debug_response', responseHandler) + this.socket!.emit('debug_command', { command: commandHex }) + }) + } + + async getVariablesList(variableIndexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: Buffer + error?: string + }> { + if (!this.socket) { + return { success: false, error: 'Not connected to target' } + } + + const functionCode = ModbusFunctionCode.DEBUG_GET_LIST + const numIndexes = variableIndexes.length + + const request = Buffer.alloc(3 + 2 * numIndexes) + request.writeUInt8(functionCode, 0) + request.writeUInt16BE(numIndexes, 1) + + for (let i = 0; i < numIndexes; i++) { + request.writeUInt16BE(variableIndexes[i], 3 + i * 2) + } + + const commandHex = this.bufferToHexString(request) + + return new Promise((resolve) => { + const timeoutHandle = setTimeout(() => { + resolve({ success: false, error: 'Request timeout' }) + }, 5000) + + const responseHandler = (response: { success: boolean; data?: string; error?: string }) => { + clearTimeout(timeoutHandle) + this.socket?.off('debug_response', responseHandler) + + if (!response.success) { + resolve({ success: false, error: response.error || 'Unknown error' }) + return + } + + if (!response.data) { + resolve({ success: false, error: 'No data in response' }) + return + } + + try { + const responseBuffer = this.hexStringToBuffer(response.data) + + if (responseBuffer.length < 2) { + resolve({ + success: false, + error: `Invalid response: too short (${responseBuffer.length} bytes, need at least 2)`, + }) + return + } + + const responseFunctionCode = responseBuffer.readUInt8(0) + const statusCode = responseBuffer.readUInt8(1) + + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { + resolve({ success: false, error: 'Function code mismatch' }) + return + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + resolve({ success: false, error: 'ERROR_OUT_OF_BOUNDS' }) + return + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + resolve({ success: false, error: 'ERROR_OUT_OF_MEMORY' }) + return + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + resolve({ success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` }) + return + } + + if (responseBuffer.length < 10) { + resolve({ + success: false, + error: `Incomplete success response (${responseBuffer.length} bytes, expected at least 10)`, + }) + return + } + + const lastIndex = responseBuffer.readUInt16BE(2) + const tick = responseBuffer.readUInt32BE(4) + const responseSize = responseBuffer.readUInt16BE(8) + + if (responseBuffer.length < 10 + responseSize) { + resolve({ + success: false, + error: `Incomplete variable data (expected ${responseSize} bytes, got ${responseBuffer.length - 10})`, + }) + return + } + + const variableData = responseBuffer.slice(10, 10 + responseSize) + + resolve({ + success: true, + tick, + lastIndex, + data: variableData, + }) + } catch (error) { + resolve({ success: false, error: getErrorMessage(error) }) + } + } + + this.socket!.on('debug_response', responseHandler) + this.socket!.emit('debug_command', { command: commandHex }) + }) + } + + async setVariable( + variableIndex: number, + force: boolean, + valueBuffer?: Buffer, + ): Promise<{ + success: boolean + error?: string + }> { + if (!this.socket) { + return { success: false, error: 'Not connected to target' } + } + + const functionCode = ModbusFunctionCode.DEBUG_SET + + const dataLength = force && valueBuffer ? valueBuffer.length : 1 + const request = Buffer.alloc(6 + dataLength) + + request.writeUInt8(functionCode, 0) + request.writeUInt16BE(variableIndex, 1) + request.writeUInt8(force ? 1 : 0, 3) + request.writeUInt16BE(dataLength, 4) + + if (force && valueBuffer) { + for (let i = 0; i < valueBuffer.length; i++) { + request.writeUInt8(valueBuffer[i], 6 + i) + } + } else { + request.writeUInt8(0, 6) + } + + const commandHex = this.bufferToHexString(request) + + return new Promise((resolve) => { + const timeoutHandle = setTimeout(() => { + resolve({ success: false, error: 'Request timeout' }) + }, 5000) + + const responseHandler = (response: { success: boolean; data?: string; error?: string }) => { + clearTimeout(timeoutHandle) + this.socket?.off('debug_response', responseHandler) + + if (!response.success) { + resolve({ success: false, error: response.error || 'Unknown error' }) + return + } + + if (!response.data) { + resolve({ success: false, error: 'No data in response' }) + return + } + + try { + const responseBuffer = this.hexStringToBuffer(response.data) + + if (responseBuffer.length < 2) { + resolve({ + success: false, + error: `Invalid response: too short (${responseBuffer.length} bytes, need at least 2)`, + }) + return + } + + const responseFunctionCode = responseBuffer.readUInt8(0) + const statusCode = responseBuffer.readUInt8(1) + + if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_SET as number)) { + resolve({ success: false, error: 'Function code mismatch' }) + return + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + resolve({ success: false, error: 'ERROR_OUT_OF_BOUNDS' }) + return + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + resolve({ success: false, error: 'ERROR_OUT_OF_MEMORY' }) + return + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + resolve({ success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` }) + return + } + + resolve({ success: true }) + } catch (error) { + resolve({ success: false, error: getErrorMessage(error) }) + } + } + + this.socket!.on('debug_response', responseHandler) + this.socket!.emit('debug_command', { command: commandHex }) + }) + } +} diff --git a/src/shared/data/mock/examples.json b/src/backend/shared-data/mock/examples.json similarity index 100% rename from src/shared/data/mock/examples.json rename to src/backend/shared-data/mock/examples.json diff --git a/src2/backend/shared-data/mock/object-to-create-project.ts b/src/backend/shared-data/mock/object-to-create-project.ts similarity index 95% rename from src2/backend/shared-data/mock/object-to-create-project.ts rename to src/backend/shared-data/mock/object-to-create-project.ts index 343f854cc..2b5cf83c2 100644 --- a/src2/backend/shared-data/mock/object-to-create-project.ts +++ b/src/backend/shared-data/mock/object-to-create-project.ts @@ -1,4 +1,4 @@ -import { formatDate } from '../../../utils' +import { formatDate } from '../../../frontend/utils/format-date' const xmlProjectAsObject = { project: { diff --git a/src/shared/data/mock/projects-data.json b/src/backend/shared-data/mock/projects-data.json similarity index 100% rename from src/shared/data/mock/projects-data.json rename to src/backend/shared-data/mock/projects-data.json diff --git a/src2/backend/shared/__tests__/validate-variable-type.test.ts b/src/backend/shared/__tests__/validate-variable-type.test.ts similarity index 96% rename from src2/backend/shared/__tests__/validate-variable-type.test.ts rename to src/backend/shared/__tests__/validate-variable-type.test.ts index c231c30ae..f9b6fe152 100644 --- a/src2/backend/shared/__tests__/validate-variable-type.test.ts +++ b/src/backend/shared/__tests__/validate-variable-type.test.ts @@ -1,4 +1,4 @@ -import { validateVariableType, getVariableRestrictionType } from '../validate-variable-type' +import { getVariableRestrictionType, validateVariableType } from '../validate-variable-type' describe('validateVariableType', () => { it('returns valid for exact type match', () => { @@ -91,6 +91,6 @@ describe('getVariableRestrictionType', () => { const result = getVariableRestrictionType('ANY_STRING') expect(result.definition).toBe('base-type') expect(Array.isArray(result.values)).toBe(true) - expect((result.values as string[])).toContain('string') + expect(result.values as string[]).toContain('string') }) }) diff --git a/src/backend/shared/debug/index.ts b/src/backend/shared/debug/index.ts new file mode 100644 index 000000000..8cc69ef57 --- /dev/null +++ b/src/backend/shared/debug/index.ts @@ -0,0 +1,2 @@ +export { ModbusRtuTransport } from './modbus-rtu-transport' +export type { DebugConnectionType, DebugSetResult, DebugTransport, DebugTransportResult } from './types' diff --git a/src2/frontend/services/debug/transports/modbus-rtu-transport.ts b/src/backend/shared/debug/modbus-rtu-transport.ts similarity index 88% rename from src2/frontend/services/debug/transports/modbus-rtu-transport.ts rename to src/backend/shared/debug/modbus-rtu-transport.ts index 677d10f37..180ee4c43 100644 --- a/src2/frontend/services/debug/transports/modbus-rtu-transport.ts +++ b/src/backend/shared/debug/modbus-rtu-transport.ts @@ -9,9 +9,9 @@ * VirtualSerialPort + ModbusRtuClient pair (MainProcessBridge lines 894-909). */ -import type { DebugTransport, DebugTransportResult, DebugSetResult } from '../types' -import { simulatorService } from '../../simulator' -import { hexToBytes, bytesToHex } from '../../../utils/hex' +import { bytesToHex, hexToBytes } from '../../../frontend/utils/hex' +import { simulatorService } from '../simulator' +import type { DebugSetResult, DebugTransport, DebugTransportResult } from './types' export class ModbusRtuTransport implements DebugTransport { async connect(): Promise { diff --git a/src2/frontend/services/debug/types.ts b/src/backend/shared/debug/types.ts similarity index 91% rename from src2/frontend/services/debug/types.ts rename to src/backend/shared/debug/types.ts index a2f910a9f..f09ece949 100644 --- a/src2/frontend/services/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -5,7 +5,7 @@ * Mirrors the implicit interface from openplc-editor where ModbusTcpClient, * ModbusRtuClient, and WebSocketDebugClient all implement the same methods. * - * openplc-web transports: ModbusRtuTransport (simulator), WebRTCTransport, HttpTransport. + * openplc-web transports: ModbusRtuTransport (simulator), ModbusDataChannelTransport (WebRTC), HttpTransport. */ export type DebugConnectionType = 'webrtc' | 'http' | 'simulator' diff --git a/src/backend/shared/simulator/index.ts b/src/backend/shared/simulator/index.ts new file mode 100644 index 000000000..2fb040b7e --- /dev/null +++ b/src/backend/shared/simulator/index.ts @@ -0,0 +1,6 @@ +export { simulatorService } from './simulator-service' +export { SimulatorModule } from './simulator-module' +export { VirtualSerialPort } from './virtual-serial-port' +export { ModbusRtuClient } from './modbus-rtu-client' +export type { SerialPortLike } from './modbus-rtu-client' +export { ModbusFunctionCode, ModbusDebugResponse } from './modbus-types' diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts new file mode 100644 index 000000000..6632ec8c8 --- /dev/null +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -0,0 +1,444 @@ +import { ModbusDebugResponse, ModbusFunctionCode } from './modbus-types' + +export interface SerialPortLike { + isOpen: boolean + open(): void + close(): void + write(data: Uint8Array, callback?: (err?: Error | null) => void): void + flush(callback?: (err?: Error | null) => void): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, listener: (...args: any[]) => void): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + once(event: string, listener: (...args: any[]) => void): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + removeListener(event: string, listener: (...args: any[]) => void): void + removeAllListeners(event?: string): void +} + +interface ModbusRtuClientOptions { + slaveId: number + timeout: number + serialPort: SerialPortLike +} + +const MD5_REQUEST_MAX_RETRIES = 3 +const MD5_REQUEST_RETRY_DELAY_MS = 500 + +const FRAME_COMPLETE_TIMEOUT_MS = 10 + +// --------------------------------------------------------------------------- +// Uint8Array helpers (replacing Node.js Buffer) +// --------------------------------------------------------------------------- + +function allocBytes(size: number): Uint8Array { + return new Uint8Array(size) +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length) + result.set(a, 0) + result.set(b, a.length) + return result +} + +function readUint8(buf: Uint8Array, offset: number): number { + return buf[offset] +} + +function writeUint8(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = value +} + +function readUint16BE(buf: Uint8Array, offset: number): number { + return (buf[offset] << 8) | buf[offset + 1] +} + +function writeUint16BE(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = (value >>> 8) & 0xff + buf[offset + 1] = value & 0xff +} + +function readUint32BE(buf: Uint8Array, offset: number): number { + return ((buf[offset] << 24) | (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3]) >>> 0 +} + +// --------------------------------------------------------------------------- +// Modbus RTU Client (web-compatible) +// --------------------------------------------------------------------------- + +export class ModbusRtuClient { + private slaveId: number + private timeout: number + private serialPort: SerialPortLike | null = null + private injectedSerialPort: SerialPortLike + + private static readonly CRC_HI_TABLE = [ + 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, + 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, + 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, + 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, + 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, + 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, + 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, + 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, + 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, + 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, + 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, + 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, + 0x00, 0xc1, 0x81, 0x40, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, + 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, + ] + + private static readonly CRC_LO_TABLE = [ + 0x00, 0xc0, 0xc1, 0x01, 0xc3, 0x03, 0x02, 0xc2, 0xc6, 0x06, 0x07, 0xc7, 0x05, 0xc5, 0xc4, 0x04, 0xcc, 0x0c, 0x0d, + 0xcd, 0x0f, 0xcf, 0xce, 0x0e, 0x0a, 0xca, 0xcb, 0x0b, 0xc9, 0x09, 0x08, 0xc8, 0xd8, 0x18, 0x19, 0xd9, 0x1b, 0xdb, + 0xda, 0x1a, 0x1e, 0xde, 0xdf, 0x1f, 0xdd, 0x1d, 0x1c, 0xdc, 0x14, 0xd4, 0xd5, 0x15, 0xd7, 0x17, 0x16, 0xd6, 0xd2, + 0x12, 0x13, 0xd3, 0x11, 0xd1, 0xd0, 0x10, 0xf0, 0x30, 0x31, 0xf1, 0x33, 0xf3, 0xf2, 0x32, 0x36, 0xf6, 0xf7, 0x37, + 0xf5, 0x35, 0x34, 0xf4, 0x3c, 0xfc, 0xfd, 0x3d, 0xff, 0x3f, 0x3e, 0xfe, 0xfa, 0x3a, 0x3b, 0xfb, 0x39, 0xf9, 0xf8, + 0x38, 0x28, 0xe8, 0xe9, 0x29, 0xeb, 0x2b, 0x2a, 0xea, 0xee, 0x2e, 0x2f, 0xef, 0x2d, 0xed, 0xec, 0x2c, 0xe4, 0x24, + 0x25, 0xe5, 0x27, 0xe7, 0xe6, 0x26, 0x22, 0xe2, 0xe3, 0x23, 0xe1, 0x21, 0x20, 0xe0, 0xa0, 0x60, 0x61, 0xa1, 0x63, + 0xa3, 0xa2, 0x62, 0x66, 0xa6, 0xa7, 0x67, 0xa5, 0x65, 0x64, 0xa4, 0x6c, 0xac, 0xad, 0x6d, 0xaf, 0x6f, 0x6e, 0xae, + 0xaa, 0x6a, 0x6b, 0xab, 0x69, 0xa9, 0xa8, 0x68, 0x78, 0xb8, 0xb9, 0x79, 0xbb, 0x7b, 0x7a, 0xba, 0xbe, 0x7e, 0x7f, + 0xbf, 0x7d, 0xbd, 0xbc, 0x7c, 0xb4, 0x74, 0x75, 0xb5, 0x77, 0xb7, 0xb6, 0x76, 0x72, 0xb2, 0xb3, 0x73, 0xb1, 0x71, + 0x70, 0xb0, 0x50, 0x90, 0x91, 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9c, + 0x5c, 0x5d, 0x9d, 0x5f, 0x9f, 0x9e, 0x5e, 0x5a, 0x9a, 0x9b, 0x5b, 0x99, 0x59, 0x58, 0x98, 0x88, 0x48, 0x49, 0x89, + 0x4b, 0x8b, 0x8a, 0x4a, 0x4e, 0x8e, 0x8f, 0x4f, 0x8d, 0x4d, 0x4c, 0x8c, 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, + 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, 0x40, + ] + + constructor(options: ModbusRtuClientOptions) { + this.slaveId = options.slaveId + this.timeout = options.timeout + this.injectedSerialPort = options.serialPort + } + + private calculateCrc(buffer: Uint8Array): number { + let crcHi = 0xff + let crcLo = 0xff + + for (let i = 0; i < buffer.length; i++) { + const index = crcHi ^ buffer[i] + crcHi = crcLo ^ ModbusRtuClient.CRC_HI_TABLE[index] + crcLo = ModbusRtuClient.CRC_LO_TABLE[index] + } + + return (crcHi << 8) | crcLo + } + + private assembleRequest(functionCode: number, data: Uint8Array): Uint8Array { + const frameWithoutCrc = allocBytes(2 + data.length) + writeUint8(frameWithoutCrc, 0, this.slaveId) + writeUint8(frameWithoutCrc, 1, functionCode) + frameWithoutCrc.set(data, 2) + + const crc = this.calculateCrc(frameWithoutCrc) + const request = allocBytes(frameWithoutCrc.length + 2) + request.set(frameWithoutCrc, 0) + writeUint16BE(request, frameWithoutCrc.length, crc) + + return request + } + + async connect(): Promise { + this.serialPort = this.injectedSerialPort + return new Promise((resolve, reject) => { + this.serialPort!.once('open', () => { + // Wait for firmware to complete setup() and initialize the USART. + // Matches ARDUINO_BOOTLOADER_DELAY_MS in the editor's ModbusRtuClient. + setTimeout(() => { + resolve() + }, 2500) + }) + this.serialPort!.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err)))) + this.serialPort!.open() + }) + } + + disconnect(): void { + if (this.serialPort && this.serialPort.isOpen) { + this.serialPort.close() + this.serialPort = null + } + } + + private flushInputBuffer(): Promise { + return new Promise((resolve) => { + if (!this.serialPort || !this.serialPort.isOpen) { + resolve() + return + } + + this.serialPort.flush((err?: Error | null) => { + if (err) { + console.warn('Warning: Failed to flush serial port:', err.message) + } + resolve() + }) + }) + } + + private sendRequestMutex: Promise = Promise.resolve() + + private async sendRequest(request: Uint8Array): Promise { + return new Promise((resolve, reject) => { + this.sendRequestMutex = this.sendRequestMutex.then( + () => this.sendRequestImpl(request).then(resolve, reject), + () => this.sendRequestImpl(request).then(resolve, reject), + ) + }) + } + + private async sendRequestImpl(request: Uint8Array): Promise { + if (!this.serialPort || !this.serialPort.isOpen) { + throw new Error('Serial port is not open') + } + + await this.flushInputBuffer() + + return new Promise((resolve, reject) => { + let responseBuffer = allocBytes(0) + let frameCompleteTimeout: ReturnType | null = null + + const cleanup = () => { + this.serialPort?.removeListener('data', onData) + this.serialPort?.removeListener('error', onError) + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + } + } + + const timeoutHandle = setTimeout(() => { + cleanup() + reject(new Error('Request timeout')) + }, this.timeout) + + const onData = (data: Uint8Array) => { + responseBuffer = concatBytes(responseBuffer, data) + + if (frameCompleteTimeout) { + clearTimeout(frameCompleteTimeout) + } + + frameCompleteTimeout = setTimeout(() => { + clearTimeout(timeoutHandle) + cleanup() + + if (responseBuffer.length < 5) { + reject(new Error('Response too short')) + return + } + + const receivedCrc = readUint16BE(responseBuffer, responseBuffer.length - 2) + const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) + + if (receivedCrc !== calculatedCrc) { + // OpenPLC debugger ignores CRC errors -- mismatch is non-fatal + } + + const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) + const paddedResponse = allocBytes(6 + responseWithoutCrc.length) + // First 6 bytes are zeros (padding for TCP header compatibility) + paddedResponse.set(responseWithoutCrc, 6) + + resolve(paddedResponse) + }, FRAME_COMPLETE_TIMEOUT_MS) + } + + const onError = (error: unknown) => { + clearTimeout(timeoutHandle) + cleanup() + reject(error instanceof Error ? error : new Error(String(error))) + } + + this.serialPort!.on('data', onData) + this.serialPort!.once('error', onError) + this.serialPort!.write(request, (error?: Error | null) => { + if (error) { + clearTimeout(timeoutHandle) + cleanup() + reject(error) + } + }) + }) + } + + async getMd5Hash(): Promise { + const functionCode = ModbusFunctionCode.DEBUG_GET_MD5 + const endiannessCheck = 0xdead + + const data = allocBytes(4) + writeUint16BE(data, 0, endiannessCheck) + writeUint8(data, 2, 0) + writeUint8(data, 3, 0) + + const request = this.assembleRequest(functionCode, data) + + let lastError: Error | null = null + for (let attempt = 0; attempt <= MD5_REQUEST_MAX_RETRIES; attempt++) { + try { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, MD5_REQUEST_RETRY_DELAY_MS)) + } + + const response = await this.sendRequest(request) + + if (response.length < 9) { + throw new Error('Invalid response: too short') + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { + throw new Error('Function code mismatch') + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + throw new Error(`Target returned error code: 0x${statusCode.toString(16)}`) + } + + const md5Bytes = response.slice(9) + const md5String = new TextDecoder().decode(md5Bytes).trim() + return md5String + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + if (attempt < MD5_REQUEST_MAX_RETRIES) { + console.warn(`MD5 request attempt ${attempt + 1} failed: ${lastError.message}. Retrying...`) + } + } + } + + throw Object.assign(new Error('Failed to get MD5 hash after retries'), { cause: lastError }) + } + + async getVariablesList(variableIndexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: Uint8Array + error?: string + }> { + try { + const functionCode = ModbusFunctionCode.DEBUG_GET_LIST + const numIndexes = variableIndexes.length + + const data = allocBytes(2 + 2 * numIndexes) + writeUint16BE(data, 0, numIndexes) + + for (let i = 0; i < numIndexes; i++) { + writeUint16BE(data, 2 + i * 2, variableIndexes[i]) + } + + const request = this.assembleRequest(functionCode, data) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { + return { success: false, error: 'Function code mismatch' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + if (response.length < 17) { + return { + success: false, + error: `Incomplete success response (${response.length} bytes, expected at least 17)`, + } + } + + const lastIndex = readUint16BE(response, 9) + const tick = readUint32BE(response, 11) + const responseSize = readUint16BE(response, 15) + + if (response.length < 17 + responseSize) { + return { + success: false, + error: `Incomplete variable data (expected ${responseSize} bytes, got ${response.length - 17})`, + } + } + + const variableData = response.slice(17, 17 + responseSize) + + return { + success: true, + tick, + lastIndex, + data: variableData, + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async setVariable( + variableIndex: number, + force: boolean, + valueBuffer?: Uint8Array, + ): Promise<{ + success: boolean + error?: string + }> { + try { + const functionCode = ModbusFunctionCode.DEBUG_SET + + const dataLength = force && valueBuffer ? valueBuffer.length : 1 + const data = allocBytes(5 + dataLength) + + writeUint16BE(data, 0, variableIndex) + writeUint8(data, 2, force ? 1 : 0) + writeUint16BE(data, 3, dataLength) + + if (force && valueBuffer) { + data.set(valueBuffer, 5) + } else { + writeUint8(data, 5, 0) + } + + const request = this.assembleRequest(functionCode, data) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_SET as number)) { + return { success: false, error: 'Function code mismatch' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } + } + + if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { success: false, error: 'ERROR_OUT_OF_MEMORY' } + } + + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } +} diff --git a/src/backend/shared/simulator/modbus-types.ts b/src/backend/shared/simulator/modbus-types.ts new file mode 100644 index 000000000..a2ac377f6 --- /dev/null +++ b/src/backend/shared/simulator/modbus-types.ts @@ -0,0 +1,13 @@ +export enum ModbusFunctionCode { + DEBUG_INFO = 0x41, + DEBUG_SET = 0x42, + DEBUG_GET = 0x43, + DEBUG_GET_LIST = 0x44, + DEBUG_GET_MD5 = 0x45, +} + +export enum ModbusDebugResponse { + SUCCESS = 0x7e, + ERROR_OUT_OF_BOUNDS = 0x81, + ERROR_OUT_OF_MEMORY = 0x82, +} diff --git a/src/backend/shared/simulator/simulator-module.ts b/src/backend/shared/simulator/simulator-module.ts new file mode 100644 index 000000000..80894882d --- /dev/null +++ b/src/backend/shared/simulator/simulator-module.ts @@ -0,0 +1,325 @@ +import { + AVRClock, + avrInstruction, + AVRTimer, + AVRUSART, + clockConfig, + CPU, + timer0Config, + timer1Config, + timer2Config, + usart0Config, +} from 'avr8js' + +// ATmega2560 specs +const CPU_FREQ_HZ = 16_000_000 +const FLASH_SIZE_BYTES = 256 * 1024 +// Expanded SRAM: fill the entire 16-bit address space (64 KB). +// The CPU constructor adds 0x100 internally for registers + standard I/O (0x00-0xFF). +// We supply 0xFF00 (65280) to cover extended I/O (0x100-0x1FF) plus usable SRAM +// (0x200-0xFFFF = 65024 bytes ~ 63.5 KB). This is the maximum addressable with +// AVR's 16-bit data pointers. The linker flags in hals.json tell avr-gcc about +// the expanded space so it actually uses it. +const SRAM_BYTES = 0xff00 + +// SLEEP opcode -- the firmware inserts `__asm volatile("sleep")` at the end +// of each loop() iteration. We detect it before execution and fast-forward +// the clock to the next timer event, avoiding millions of idle cycles. +const SLEEP_OPCODE = 0x9588 + +// Nanoseconds per CPU cycle at 16 MHz +const CYCLE_NS = 1e9 / CPU_FREQ_HZ // 62.5 ns + +// Maximum real (non-skipped) instructions per batch. SLEEP fast-forwards +// don't count against this budget, so idle periods are essentially free. +const MAX_REAL_INSTRUCTIONS = 100_000 + +// Maximum simulated time per batch (in CPU cycles). Prevents runaway +// batches when the firmware is mostly idle (SLEEP fast-forwards could +// cover seconds of sim time without hitting the instruction limit). +const MAX_SIM_CYCLES_PER_BATCH = CPU_FREQ_HZ / 10 // 100ms + +// --------------------------------------------------------------------------- +// ATmega2560 peripheral configs -- register addresses are identical to the +// ATmega328p defaults exported by avr8js, only the interrupt vector addresses +// differ because ATmega2560 has more interrupt sources. +// Vector addresses are word addresses matching the datasheet. +// --------------------------------------------------------------------------- + +// ATmega2560 vector addresses (word addresses). +// IMPORTANT: ATmega2560 has TIMER1_COMPC at vector 19 (word 0x26) which +// ATmega328p lacks. This shifts Timer1 OVF and all subsequent vectors by 2 +// compared to a naive mapping from the ATmega328p table. +// Vectors verified against avr-objdump of compiled firmware. +const mega2560Timer0Config = { + ...timer0Config, + compAInterrupt: 0x2a, // vector 21 + compBInterrupt: 0x2c, // vector 22 + ovfInterrupt: 0x2e, // vector 23 +} + +const mega2560Timer1Config = { + ...timer1Config, + captureInterrupt: 0x20, // vector 16 + compAInterrupt: 0x22, // vector 17 + compBInterrupt: 0x24, // vector 18 + // Note: TIMER1_COMPC at vector 19 (0x26) not modeled by avr8js + ovfInterrupt: 0x28, // vector 20 +} + +const mega2560Timer2Config = { + ...timer2Config, + compAInterrupt: 0x1a, // vector 13 + compBInterrupt: 0x1c, // vector 14 + ovfInterrupt: 0x1e, // vector 15 +} + +const mega2560Usart0Config = { + ...usart0Config, + rxCompleteInterrupt: 0x32, // vector 25 + dataRegisterEmptyInterrupt: 0x34, // vector 26 + txCompleteInterrupt: 0x36, // vector 27 +} + +// --------------------------------------------------------------------------- +// Intel HEX parser +// --------------------------------------------------------------------------- + +/** + * Parses an Intel HEX string into a Uint16Array suitable for the AVR CPU. + * Supports record types 00 (data), 01 (EOF), 02 (extended segment address), + * and 04 (extended linear address) for flash sizes >64 KB. + */ +function parseIntelHex(hex: string, flashSizeBytes: number): Uint16Array { + const flash = new Uint8Array(flashSizeBytes) + let extendedAddress = 0 + + for (const rawLine of hex.split('\n')) { + const line = rawLine.trim() + if (!line.startsWith(':')) continue + + const byteCount = parseInt(line.substring(1, 3), 16) + const address = parseInt(line.substring(3, 7), 16) + const recordType = parseInt(line.substring(7, 9), 16) + + if (recordType === 0x00) { + // Data record + const fullAddress = extendedAddress + address + for (let i = 0; i < byteCount; i++) { + const byte = parseInt(line.substring(9 + i * 2, 11 + i * 2), 16) + if (fullAddress + i < flashSizeBytes) { + flash[fullAddress + i] = byte + } + } + } else if (recordType === 0x01) { + // End of file + break + } else if (recordType === 0x02) { + // Extended segment address (address << 4) + extendedAddress = parseInt(line.substring(9, 13), 16) << 4 + } else if (recordType === 0x04) { + // Extended linear address (upper 16 bits) + extendedAddress = parseInt(line.substring(9, 13), 16) << 16 + } + } + + // Convert byte array to 16-bit little-endian words for the AVR CPU + const words = new Uint16Array(flashSizeBytes / 2) + for (let i = 0; i < flashSizeBytes; i += 2) { + words[i / 2] = flash[i] | (flash[i + 1] << 8) + } + return words +} + +// --------------------------------------------------------------------------- +// Simulator module +// --------------------------------------------------------------------------- + +/** + * Manages the avr8js ATmega2560 emulator lifecycle in the browser. + * + * The firmware is compiled with SIMULATOR_MODE which inserts a SLEEP + * instruction at the end of each loop() iteration. When the CPU hits SLEEP, + * the execution loop fast-forwards the clock to the next timer event + * (typically Timer0 overflow at ~1 ms), avoiding millions of wasted + * busy-wait instruction cycles and allowing the simulation to run at + * near real-time speed. + */ +export class SimulatorModule { + private cpu: CPU | null = null + private running = false + private timerHandle: ReturnType | null = null + + // Peripherals (kept alive so they process register read/write hooks) + private timer0: AVRTimer | null = null + private timer1: AVRTimer | null = null + private timer2: AVRTimer | null = null + private usart0: AVRUSART | null = null + private clock: AVRClock | null = null + + // RX byte queue -- avr8js USART accepts one byte at a time (returns false + // while rxBusy). Incoming bytes are queued and drained after the firmware + // reads UDR (via the read hook), ensuring the RXC ISR processes each byte + // before the next one overwrites rxByte. + private rxQueue: number[] = [] + + // Wall-clock pacing + private wallStartMs = 0 + private simStartCycles = 0 + + /** Callback fired for each byte transmitted by the emulated USART0 */ + onUartByte: ((byte: number) => void) | null = null + + /** + * Loads Intel HEX firmware content and starts the emulated ATmega2560. + * Stops any currently running emulation first. + * + * @param hexContent - Raw Intel HEX string (caller is responsible for reading the file) + */ + loadAndRun(hexContent: string): void { + this.stop() + + const progMem = parseIntelHex(hexContent, FLASH_SIZE_BYTES) + + this.cpu = new CPU(progMem, SRAM_BYTES) + + // Instantiate peripherals -- they register read/write hooks on the CPU + this.timer0 = new AVRTimer(this.cpu, mega2560Timer0Config) + this.timer1 = new AVRTimer(this.cpu, mega2560Timer1Config) + this.timer2 = new AVRTimer(this.cpu, mega2560Timer2Config) + this.usart0 = new AVRUSART(this.cpu, mega2560Usart0Config, CPU_FREQ_HZ) + this.clock = new AVRClock(this.cpu, CPU_FREQ_HZ, clockConfig) + + // Wrap the UDR read hook so that after the firmware reads a received byte, + // the next queued byte is fed into the USART. This ensures the RXC ISR + // has consumed the current byte before the next one arrives, preventing + // rxByte from being silently overwritten when interrupts are disabled + // (e.g. while the CPU is inside another ISR like Timer0). + const udrAddr = mega2560Usart0Config.UDR + const originalUdrReadHook = this.cpu.readHooks[udrAddr] + this.cpu.readHooks[udrAddr] = (addr: number) => { + const result = originalUdrReadHook?.(addr) + this.drainRxQueue() + return result + } + + // Wire USART0 TX to the Modbus RTU bridge callback + this.usart0.onByteTransmit = (byte: number) => { + this.onUartByte?.(byte) + } + + // Begin execution + this.running = true + this.wallStartMs = performance.now() + this.simStartCycles = 0 + this.executeBatch() + } + + /** + * Runs a batch of CPU instructions, then reschedules. + * + * When the CPU hits a SLEEP opcode, the loop fast-forwards the clock to + * the next scheduled timer event instead of stepping through idle cycles. + * SLEEP fast-forwards don't count against the instruction budget, so idle + * periods between scan cycles are essentially free. + * + * After each batch, compares simulated time against wall time: + * - If sim is ahead: schedules next batch with setTimeout(delay) to + * let wall time catch up, keeping timers accurate. + * - If sim is behind or on time: schedules with setTimeout(0). + */ + private executeBatch = (): void => { + if (!this.running || !this.cpu) return + + this.timerHandle = null + const { cpu } = this + + // Kick-start the RX delivery chain if bytes are queued. + if (this.rxQueue.length > 0 && this.usart0) { + const byte = this.rxQueue[0] + if (this.usart0.writeByte(byte)) { + this.rxQueue.shift() + } + } + + const simCycleCap = cpu.cycles + MAX_SIM_CYCLES_PER_BATCH + let realCount = 0 + + while (this.running && realCount < MAX_REAL_INSTRUCTIONS && cpu.cycles < simCycleCap) { + if (cpu.progMem[cpu.pc] === SLEEP_OPCODE) { + // Execute the SLEEP instruction (advances PC, adds 1 cycle) + avrInstruction(cpu) + // Fast-forward to next scheduled clock event. + // NOTE: nextClockEvent is private in avr8js -- pinned to 0.20.0 in package.json. + // If upgrading avr8js, verify this field still exists and has a `cycles` property. + const nextEvent = (cpu as unknown as { nextClockEvent: { cycles: number } | null }).nextClockEvent + if (nextEvent && nextEvent.cycles > cpu.cycles) { + cpu.cycles = nextEvent.cycles + } + cpu.tick() + } else { + avrInstruction(cpu) + cpu.tick() + realCount++ + } + } + + if (this.running) { + // Pace simulation to wall time + const simElapsedMs = ((cpu.cycles - this.simStartCycles) * CYCLE_NS) / 1e6 + const wallElapsedMs = performance.now() - this.wallStartMs + const aheadMs = simElapsedMs - wallElapsedMs + this.timerHandle = setTimeout(this.executeBatch, aheadMs > 1 ? Math.floor(aheadMs) : 0) + } + } + + /** Send a byte to the emulated USART0 RX (host -> device) */ + feedByte(byte: number): void { + this.rxQueue.push(byte) + if (this.rxQueue.length === 1 && this.usart0) { + const accepted = this.usart0.writeByte(byte) + if (accepted) { + this.rxQueue.shift() + } + } + } + + /** + * Tries to deliver the next queued byte to the USART. Called after the + * firmware reads UDR (via the read hook). This pacing ensures the RXC ISR + * processes each byte before the next one arrives, avoiding data loss + * from rxByte overwrites. + */ + private drainRxQueue(): void { + if (!this.usart0 || this.rxQueue.length === 0) return + const byte = this.rxQueue[0] + if (this.usart0.writeByte(byte)) { + this.rxQueue.shift() + } + } + + /** Stop the emulator and release resources */ + stop(): void { + this.running = false + if (this.timerHandle !== null) { + clearTimeout(this.timerHandle) + this.timerHandle = null + } + if (this.usart0) { + this.usart0.onByteTransmit = null + this.usart0 = null + } + this.rxQueue = [] + this.cpu = null + this.timer0 = null + this.timer1 = null + this.timer2 = null + this.clock = null + this.onUartByte = null + } + + /** Check if the emulator is currently running */ + isRunning(): boolean { + return this.running + } +} diff --git a/src2/frontend/services/simulator/simulator-service.ts b/src/backend/shared/simulator/simulator-service.ts similarity index 95% rename from src2/frontend/services/simulator/simulator-service.ts rename to src/backend/shared/simulator/simulator-service.ts index 2b0620940..27d3aadf0 100644 --- a/src2/frontend/services/simulator/simulator-service.ts +++ b/src/backend/shared/simulator/simulator-service.ts @@ -94,11 +94,7 @@ class SimulatorServiceFacade { /** * Force or release a variable in the simulator. */ - async setVariable( - index: number, - force: boolean, - valueHex?: string, - ): Promise<{ success: boolean; error?: string }> { + async setVariable(index: number, force: boolean, valueHex?: string): Promise<{ success: boolean; error?: string }> { if (!this.port) return { success: false, error: 'SimulatorPort not registered' } return this.port.setDebugVariable(index, force, valueHex) } diff --git a/src/backend/shared/simulator/virtual-serial-port.ts b/src/backend/shared/simulator/virtual-serial-port.ts new file mode 100644 index 000000000..a7f353509 --- /dev/null +++ b/src/backend/shared/simulator/virtual-serial-port.ts @@ -0,0 +1,87 @@ +import { SimulatorModule } from './simulator-module' + +/** + * A virtual serial port that mimics the `serialport` npm package's event-based API. + * Routes bytes through SimulatorModule's UART bridge, allowing the existing + * ModbusRtuClient to communicate with the avr8js emulator unchanged. + * + * Uses manual listener arrays instead of Node.js EventEmitter for browser compatibility. + */ +export class VirtualSerialPort { + public isOpen = false + private simulator: SimulatorModule + + private dataListeners: ((data: Uint8Array) => void)[] = [] + private openListeners: (() => void)[] = [] + private errorListeners: ((err: Error) => void)[] = [] + + constructor(simulator: SimulatorModule) { + this.simulator = simulator + } + + open(): void { + this.isOpen = true + // Wire UART RX: bytes from emulated device -> ModbusRtuClient via 'data' events + this.simulator.onUartByte = (byte: number) => { + const buf = new Uint8Array([byte]) + for (const cb of this.dataListeners) { + cb(buf) + } + } + // Emit 'open' asynchronously (matches real SerialPort behavior) + queueMicrotask(() => { + for (const cb of this.openListeners) { + cb() + } + }) + } + + write(data: Uint8Array, callback?: (err?: Error | null) => void): void { + // Send each byte to the emulated UART TX (host -> device) + for (const byte of data) { + this.simulator.feedByte(byte) + } + callback?.(null) + } + + flush(callback?: (err?: Error | null) => void): void { + // No hardware buffer to flush in virtual port + callback?.(null) + } + + close(): void { + this.isOpen = false + this.simulator.onUartByte = null + this.removeAllListeners() + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, listener: (...args: any[]) => void): void { + if (event === 'data') this.dataListeners.push(listener) + else if (event === 'open') this.openListeners.push(listener) + else if (event === 'error') this.errorListeners.push(listener) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + once(event: string, listener: (...args: any[]) => void): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapper = (...args: any[]) => { + this.removeListener(event, wrapper) + listener(...args) + } + this.on(event, wrapper) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + removeListener(event: string, listener: (...args: any[]) => void): void { + if (event === 'data') this.dataListeners = this.dataListeners.filter((cb) => cb !== listener) + else if (event === 'open') this.openListeners = this.openListeners.filter((cb) => cb !== listener) + else if (event === 'error') this.errorListeners = this.errorListeners.filter((cb) => cb !== listener) + } + + removeAllListeners(event?: string): void { + if (!event || event === 'data') this.dataListeners = [] + if (!event || event === 'open') this.openListeners = [] + if (!event || event === 'error') this.errorListeners = [] + } +} diff --git a/src/renderer/styles/globals.css b/src/backend/styles/globals.css similarity index 100% rename from src/renderer/styles/globals.css rename to src/backend/styles/globals.css diff --git a/src/renderer/assets/folder.png b/src/frontend/assets/folder.png similarity index 100% rename from src/renderer/assets/folder.png rename to src/frontend/assets/folder.png diff --git a/src2/frontend/assets/icons/Fallback.tsx b/src/frontend/assets/icons/Fallback.tsx similarity index 99% rename from src2/frontend/assets/icons/Fallback.tsx rename to src/frontend/assets/icons/Fallback.tsx index 5658a7587..1bbdadee2 100644 --- a/src2/frontend/assets/icons/Fallback.tsx +++ b/src/frontend/assets/icons/Fallback.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../utils/cn' + type IFallBackIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src/renderer/assets/icons/Types/iconTypes.tsx b/src/frontend/assets/icons/Types/iconTypes.tsx similarity index 100% rename from src/renderer/assets/icons/Types/iconTypes.tsx rename to src/frontend/assets/icons/Types/iconTypes.tsx diff --git a/src/renderer/assets/icons/about/logo.svg b/src/frontend/assets/icons/about/logo.svg similarity index 100% rename from src/renderer/assets/icons/about/logo.svg rename to src/frontend/assets/icons/about/logo.svg diff --git a/src/renderer/assets/icons/base/left-arrow.svg b/src/frontend/assets/icons/base/left-arrow.svg similarity index 100% rename from src/renderer/assets/icons/base/left-arrow.svg rename to src/frontend/assets/icons/base/left-arrow.svg diff --git a/src/renderer/assets/icons/base/right-arrow.svg b/src/frontend/assets/icons/base/right-arrow.svg similarity index 100% rename from src/renderer/assets/icons/base/right-arrow.svg rename to src/frontend/assets/icons/base/right-arrow.svg diff --git a/src/renderer/assets/icons/flow/Coil.tsx b/src/frontend/assets/icons/flow/Coil.tsx similarity index 100% rename from src/renderer/assets/icons/flow/Coil.tsx rename to src/frontend/assets/icons/flow/Coil.tsx diff --git a/src/renderer/assets/icons/flow/Contact.tsx b/src/frontend/assets/icons/flow/Contact.tsx similarity index 100% rename from src/renderer/assets/icons/flow/Contact.tsx rename to src/frontend/assets/icons/flow/Contact.tsx diff --git a/src/renderer/assets/icons/flow/Placeholder.tsx b/src/frontend/assets/icons/flow/Placeholder.tsx similarity index 100% rename from src/renderer/assets/icons/flow/Placeholder.tsx rename to src/frontend/assets/icons/flow/Placeholder.tsx diff --git a/src2/frontend/assets/icons/interface/Arrow.tsx b/src/frontend/assets/icons/interface/Arrow.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Arrow.tsx rename to src/frontend/assets/icons/interface/Arrow.tsx index a2f956ea2..ef3a524a0 100644 --- a/src2/frontend/assets/icons/interface/Arrow.tsx +++ b/src/frontend/assets/icons/interface/Arrow.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithRef, ElementType } from 'react' +import { cn } from '../../../utils/cn' + type IArrowIconProps = ComponentPropsWithRef<'svg'> & { variant?: 'default' | 'primary' | 'secondary' direction?: 'up' | 'down' | 'left' | 'right' diff --git a/src2/frontend/assets/icons/interface/ArrowButton.tsx b/src/frontend/assets/icons/interface/ArrowButton.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/ArrowButton.tsx rename to src/frontend/assets/icons/interface/ArrowButton.tsx index f83e09720..c5684b94b 100644 --- a/src2/frontend/assets/icons/interface/ArrowButton.tsx +++ b/src/frontend/assets/icons/interface/ArrowButton.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithRef, ElementType } from 'react' +import { cn } from '../../../utils/cn' + type IArrowButtonIconProps = ComponentPropsWithRef<'svg'> & { variant?: 'default' | 'primary' | 'secondary' direction?: 'up' | 'down' | 'left' | 'right' diff --git a/src2/frontend/assets/icons/interface/ArrowUp.tsx b/src/frontend/assets/icons/interface/ArrowUp.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/ArrowUp.tsx rename to src/frontend/assets/icons/interface/ArrowUp.tsx index e2b94f931..c5c4f1f17 100644 --- a/src2/frontend/assets/icons/interface/ArrowUp.tsx +++ b/src/frontend/assets/icons/interface/ArrowUp.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type IArrowUpIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Book.tsx b/src/frontend/assets/icons/interface/Book.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Book.tsx rename to src/frontend/assets/icons/interface/Book.tsx index 83c99f2c4..f425300a8 100644 --- a/src2/frontend/assets/icons/interface/Book.tsx +++ b/src/frontend/assets/icons/interface/Book.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type IBookIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src/renderer/assets/icons/interface/Broom.tsx b/src/frontend/assets/icons/interface/Broom.tsx similarity index 100% rename from src/renderer/assets/icons/interface/Broom.tsx rename to src/frontend/assets/icons/interface/Broom.tsx diff --git a/src2/frontend/assets/icons/interface/Close.tsx b/src/frontend/assets/icons/interface/Close.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Close.tsx rename to src/frontend/assets/icons/interface/Close.tsx index c2af6b036..f1af66ded 100644 --- a/src2/frontend/assets/icons/interface/Close.tsx +++ b/src/frontend/assets/icons/interface/Close.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + const CloseIcon = (props: ComponentPropsWithoutRef<'svg'>) => { const { className, ...rest } = props return ( diff --git a/src2/frontend/assets/icons/interface/CodeIcon.tsx b/src/frontend/assets/icons/interface/CodeIcon.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/CodeIcon.tsx rename to src/frontend/assets/icons/interface/CodeIcon.tsx index 2e1f20ebe..dcab97d35 100644 --- a/src2/frontend/assets/icons/interface/CodeIcon.tsx +++ b/src/frontend/assets/icons/interface/CodeIcon.tsx @@ -1,6 +1,7 @@ +import { ComponentProps } from 'react' + import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' -import { ComponentProps } from 'react' type IProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' diff --git a/src2/frontend/assets/icons/interface/Comment.tsx b/src/frontend/assets/icons/interface/Comment.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Comment.tsx rename to src/frontend/assets/icons/interface/Comment.tsx index 152ac3e88..19990fd1c 100644 --- a/src2/frontend/assets/icons/interface/Comment.tsx +++ b/src/frontend/assets/icons/interface/Comment.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + const CommentIcon = (props: ComponentPropsWithoutRef<'svg'>) => { const { className, ...rest } = props return ( diff --git a/src2/frontend/assets/icons/interface/Config.tsx b/src/frontend/assets/icons/interface/Config.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Config.tsx rename to src/frontend/assets/icons/interface/Config.tsx index 9b62bff75..c1e0af373 100644 --- a/src2/frontend/assets/icons/interface/Config.tsx +++ b/src/frontend/assets/icons/interface/Config.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type IConfigIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/DarkTheme.tsx b/src/frontend/assets/icons/interface/DarkTheme.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/DarkTheme.tsx rename to src/frontend/assets/icons/interface/DarkTheme.tsx index 49f6ac4ce..b908f9318 100644 --- a/src2/frontend/assets/icons/interface/DarkTheme.tsx +++ b/src/frontend/assets/icons/interface/DarkTheme.tsx @@ -1,7 +1,6 @@ +import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' - import { IIconProps } from '../Types/iconTypes' -import { IconStyles } from '../../../data/constants/icon-styles' export const DarkThemeIcon = (props: IIconProps) => { const { className, size = 'md', ...res } = props diff --git a/src2/frontend/assets/icons/interface/Debugger.tsx b/src/frontend/assets/icons/interface/Debugger.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Debugger.tsx rename to src/frontend/assets/icons/interface/Debugger.tsx index 4183920ff..4050c9e71 100644 --- a/src2/frontend/assets/icons/interface/Debugger.tsx +++ b/src/frontend/assets/icons/interface/Debugger.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + type IDebuggerIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' variant?: 'default' | 'muted' diff --git a/src2/frontend/assets/icons/interface/DebuggerIcon.tsx b/src/frontend/assets/icons/interface/DebuggerIcon.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/DebuggerIcon.tsx rename to src/frontend/assets/icons/interface/DebuggerIcon.tsx index ba59b5c21..d6dae96d5 100644 --- a/src2/frontend/assets/icons/interface/DebuggerIcon.tsx +++ b/src/frontend/assets/icons/interface/DebuggerIcon.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type IDebuggerIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/interface/DeviceTransfer.tsx b/src/frontend/assets/icons/interface/DeviceTransfer.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/DeviceTransfer.tsx rename to src/frontend/assets/icons/interface/DeviceTransfer.tsx index 2963f80fd..6ee813432 100644 --- a/src2/frontend/assets/icons/interface/DeviceTransfer.tsx +++ b/src/frontend/assets/icons/interface/DeviceTransfer.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type IDeviceTransferIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Download.tsx b/src/frontend/assets/icons/interface/Download.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Download.tsx rename to src/frontend/assets/icons/interface/Download.tsx index f8070266b..0d1f8c7d8 100644 --- a/src2/frontend/assets/icons/interface/Download.tsx +++ b/src/frontend/assets/icons/interface/Download.tsx @@ -1,6 +1,5 @@ -import { cn } from '../../../utils/cn' - import { IconStyles } from '../../../data/constants/icon-styles' +import { cn } from '../../../utils/cn' import { IIconProps } from '../Types/iconTypes' export const DownloadIcon = (props: IIconProps) => { diff --git a/src/renderer/assets/icons/interface/DragHandle.tsx b/src/frontend/assets/icons/interface/DragHandle.tsx similarity index 100% rename from src/renderer/assets/icons/interface/DragHandle.tsx rename to src/frontend/assets/icons/interface/DragHandle.tsx diff --git a/src2/frontend/assets/icons/interface/Duplicate.tsx b/src/frontend/assets/icons/interface/Duplicate.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Duplicate.tsx rename to src/frontend/assets/icons/interface/Duplicate.tsx index 6c04675af..ac1b43001 100644 --- a/src2/frontend/assets/icons/interface/Duplicate.tsx +++ b/src/frontend/assets/icons/interface/Duplicate.tsx @@ -1,8 +1,6 @@ +import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' - import { IIconProps } from '../Types/iconTypes' -import { IconStyles } from '../../../data/constants/icon-styles' - export const DuplicateIcon = (props: IIconProps) => { const { className, size = 'sm', ...res } = props diff --git a/src2/frontend/assets/icons/interface/DuplicateIcon.tsx b/src/frontend/assets/icons/interface/DuplicateIcon.tsx similarity index 63% rename from src2/frontend/assets/icons/interface/DuplicateIcon.tsx rename to src/frontend/assets/icons/interface/DuplicateIcon.tsx index e689d1cd3..b0a1f2486 100644 --- a/src2/frontend/assets/icons/interface/DuplicateIcon.tsx +++ b/src/frontend/assets/icons/interface/DuplicateIcon.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + type IDuplicateIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } @@ -22,25 +23,8 @@ export const DuplicateIcon = (props: IDuplicateIconProps) => { {...res} > Duplicate Icon - - + + ) } diff --git a/src2/frontend/assets/icons/interface/EditButton.tsx b/src/frontend/assets/icons/interface/EditButton.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/EditButton.tsx rename to src/frontend/assets/icons/interface/EditButton.tsx index e13510c1a..692931755 100644 --- a/src2/frontend/assets/icons/interface/EditButton.tsx +++ b/src/frontend/assets/icons/interface/EditButton.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithRef, forwardRef } from 'react' +import { cn } from '../../../utils/cn' + type IEditButtonIconProps = ComponentPropsWithRef<'svg'> & { variant?: 'default' | 'primary' | 'secondary' direction?: 'up' | 'down' | 'left' | 'right' diff --git a/src2/frontend/assets/icons/interface/Exit.tsx b/src/frontend/assets/icons/interface/Exit.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Exit.tsx rename to src/frontend/assets/icons/interface/Exit.tsx index 2a63d18a9..1c2d5de05 100644 --- a/src2/frontend/assets/icons/interface/Exit.tsx +++ b/src/frontend/assets/icons/interface/Exit.tsx @@ -1,7 +1,6 @@ +import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' - import { IIconProps } from '../Types/iconTypes' -import { IconStyles } from '../../../data/constants/icon-styles' export const ExitIcon = (props: IIconProps) => { const { className, size = 'sm', ...res } = props diff --git a/src2/frontend/assets/icons/interface/Folder.tsx b/src/frontend/assets/icons/interface/Folder.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Folder.tsx rename to src/frontend/assets/icons/interface/Folder.tsx index 97ba694cf..a6d2ff97b 100644 --- a/src2/frontend/assets/icons/interface/Folder.tsx +++ b/src/frontend/assets/icons/interface/Folder.tsx @@ -1,5 +1,6 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' + +import { cn } from '../../../utils/cn' type IFolderIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/LightTheme.tsx b/src/frontend/assets/icons/interface/LightTheme.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/LightTheme.tsx rename to src/frontend/assets/icons/interface/LightTheme.tsx index 5ec05cd49..c582a0d7e 100644 --- a/src2/frontend/assets/icons/interface/LightTheme.tsx +++ b/src/frontend/assets/icons/interface/LightTheme.tsx @@ -1,8 +1,6 @@ +import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' - import { IIconProps } from '../Types/iconTypes' -import { IconStyles } from '../../../data/constants/icon-styles' - export const LightThemeIcon = (props: IIconProps) => { const { className, size = 'md', ...res } = props diff --git a/src2/frontend/assets/icons/interface/Logo.tsx b/src/frontend/assets/icons/interface/Logo.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Logo.tsx rename to src/frontend/assets/icons/interface/Logo.tsx index b3f92de1a..72dcb9643 100644 --- a/src2/frontend/assets/icons/interface/Logo.tsx +++ b/src/frontend/assets/icons/interface/Logo.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type LogoIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Magnifier.tsx b/src/frontend/assets/icons/interface/Magnifier.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Magnifier.tsx rename to src/frontend/assets/icons/interface/Magnifier.tsx index 5272fc511..b09b40ab7 100644 --- a/src2/frontend/assets/icons/interface/Magnifier.tsx +++ b/src/frontend/assets/icons/interface/Magnifier.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type IMagnifierIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Minus.tsx b/src/frontend/assets/icons/interface/Minus.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Minus.tsx rename to src/frontend/assets/icons/interface/Minus.tsx index f2986d49d..f533acc4c 100644 --- a/src2/frontend/assets/icons/interface/Minus.tsx +++ b/src/frontend/assets/icons/interface/Minus.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + type IMinusIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/MoreOptions.tsx b/src/frontend/assets/icons/interface/MoreOptions.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/MoreOptions.tsx rename to src/frontend/assets/icons/interface/MoreOptions.tsx index 3393f6688..64f3ae98e 100644 --- a/src2/frontend/assets/icons/interface/MoreOptions.tsx +++ b/src/frontend/assets/icons/interface/MoreOptions.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + const MoreOptionsIcon = ({ className, ...rest }: ComponentPropsWithoutRef<'svg'>) => { return ( & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Pause.tsx b/src/frontend/assets/icons/interface/Pause.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Pause.tsx rename to src/frontend/assets/icons/interface/Pause.tsx index e3aa9e365..e752d1b8d 100644 --- a/src2/frontend/assets/icons/interface/Pause.tsx +++ b/src/frontend/assets/icons/interface/Pause.tsx @@ -1,8 +1,6 @@ +import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' - import { IIconProps } from '../Types/iconTypes' -import { IconStyles } from '../../../data/constants/icon-styles' - export const PauseIcon = (props: IIconProps) => { const { className, size = 'sm', ...res } = props diff --git a/src2/frontend/assets/icons/interface/Pencil.tsx b/src/frontend/assets/icons/interface/Pencil.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Pencil.tsx rename to src/frontend/assets/icons/interface/Pencil.tsx index ace6722d9..4338eecdc 100644 --- a/src2/frontend/assets/icons/interface/Pencil.tsx +++ b/src/frontend/assets/icons/interface/Pencil.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + type IPencilIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Play.tsx b/src/frontend/assets/icons/interface/Play.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Play.tsx rename to src/frontend/assets/icons/interface/Play.tsx index 4c8c7cf15..c903b8447 100644 --- a/src2/frontend/assets/icons/interface/Play.tsx +++ b/src/frontend/assets/icons/interface/Play.tsx @@ -1,6 +1,5 @@ -import { cn } from '../../../utils/cn' - import { IconStyles } from '../../../data/constants/icon-styles' +import { cn } from '../../../utils/cn' import { IIconProps } from '../Types/iconTypes' export const PlayIcon = (props: IIconProps) => { diff --git a/src2/frontend/assets/icons/interface/Plus.tsx b/src/frontend/assets/icons/interface/Plus.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Plus.tsx rename to src/frontend/assets/icons/interface/Plus.tsx index 43181bb0d..818909851 100644 --- a/src2/frontend/assets/icons/interface/Plus.tsx +++ b/src/frontend/assets/icons/interface/Plus.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + type IPlusIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Prohibited.tsx b/src/frontend/assets/icons/interface/Prohibited.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Prohibited.tsx rename to src/frontend/assets/icons/interface/Prohibited.tsx index 8037b68b8..4aacc5ea0 100644 --- a/src2/frontend/assets/icons/interface/Prohibited.tsx +++ b/src/frontend/assets/icons/interface/Prohibited.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + type ProhibitedProps = ComponentPropsWithoutRef<'svg'> export const ProhibitedIcon = (props: ProhibitedProps) => { diff --git a/src2/frontend/assets/icons/interface/Recent.tsx b/src/frontend/assets/icons/interface/Recent.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Recent.tsx rename to src/frontend/assets/icons/interface/Recent.tsx index 410f545bb..b38511971 100644 --- a/src2/frontend/assets/icons/interface/Recent.tsx +++ b/src/frontend/assets/icons/interface/Recent.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type IRecentIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Refresh.tsx b/src/frontend/assets/icons/interface/Refresh.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Refresh.tsx rename to src/frontend/assets/icons/interface/Refresh.tsx index 5f33423e3..6b9a2ebc6 100644 --- a/src2/frontend/assets/icons/interface/Refresh.tsx +++ b/src/frontend/assets/icons/interface/Refresh.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type RefreshIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Search.tsx b/src/frontend/assets/icons/interface/Search.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Search.tsx rename to src/frontend/assets/icons/interface/Search.tsx index 6cfbbf211..9b2cf403f 100644 --- a/src2/frontend/assets/icons/interface/Search.tsx +++ b/src/frontend/assets/icons/interface/Search.tsx @@ -1,6 +1,5 @@ -import { cn } from '../../../utils/cn' - import { IconStyles } from '../../../data/constants/icon-styles' +import { cn } from '../../../utils/cn' import { IIconProps } from '../Types/iconTypes' export const SearchIcon = (props: IIconProps) => { diff --git a/src2/frontend/assets/icons/interface/StickArrow.tsx b/src/frontend/assets/icons/interface/StickArrow.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/StickArrow.tsx rename to src/frontend/assets/icons/interface/StickArrow.tsx index f8a69ab9a..38a86e976 100644 --- a/src2/frontend/assets/icons/interface/StickArrow.tsx +++ b/src/frontend/assets/icons/interface/StickArrow.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + type IStickArrowIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' direction?: 'up' | 'down' | 'left' | 'right' diff --git a/src2/frontend/assets/icons/interface/Stop.tsx b/src/frontend/assets/icons/interface/Stop.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Stop.tsx rename to src/frontend/assets/icons/interface/Stop.tsx index 540162e38..b17c7db4b 100644 --- a/src2/frontend/assets/icons/interface/Stop.tsx +++ b/src/frontend/assets/icons/interface/Stop.tsx @@ -1,6 +1,5 @@ -import { cn } from '../../../utils/cn' - import { IconStyles } from '../../../data/constants/icon-styles' +import { cn } from '../../../utils/cn' import { IIconProps } from '../Types/iconTypes' export const StopIcon = (props: IIconProps) => { diff --git a/src2/frontend/assets/icons/interface/TableIcon.tsx b/src/frontend/assets/icons/interface/TableIcon.tsx similarity index 100% rename from src2/frontend/assets/icons/interface/TableIcon.tsx rename to src/frontend/assets/icons/interface/TableIcon.tsx index e73b1d97c..c54960d58 100644 --- a/src2/frontend/assets/icons/interface/TableIcon.tsx +++ b/src/frontend/assets/icons/interface/TableIcon.tsx @@ -1,7 +1,7 @@ +import { ComponentProps } from 'react' import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' -import { ComponentProps } from 'react' type IProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' diff --git a/src2/frontend/assets/icons/interface/Timer.tsx b/src/frontend/assets/icons/interface/Timer.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Timer.tsx rename to src/frontend/assets/icons/interface/Timer.tsx index 5d381a793..ea2d879b8 100644 --- a/src2/frontend/assets/icons/interface/Timer.tsx +++ b/src/frontend/assets/icons/interface/Timer.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type IFBDIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/Transfer.tsx b/src/frontend/assets/icons/interface/Transfer.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Transfer.tsx rename to src/frontend/assets/icons/interface/Transfer.tsx index 10784c705..215e9f265 100644 --- a/src2/frontend/assets/icons/interface/Transfer.tsx +++ b/src/frontend/assets/icons/interface/Transfer.tsx @@ -1,6 +1,5 @@ -import { cn } from '../../../utils/cn' - import { IconStyles } from '../../../data/constants/icon-styles' +import { cn } from '../../../utils/cn' import { IIconProps } from '../Types/iconTypes' export const TransferIcon = (props: IIconProps) => { diff --git a/src2/frontend/assets/icons/interface/TrashCan.tsx b/src/frontend/assets/icons/interface/TrashCan.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/TrashCan.tsx rename to src/frontend/assets/icons/interface/TrashCan.tsx index 461ded6b1..e07f42c93 100644 --- a/src2/frontend/assets/icons/interface/TrashCan.tsx +++ b/src/frontend/assets/icons/interface/TrashCan.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../utils/cn' + type TrashCanProps = ComponentPropsWithoutRef<'svg'> export const TrashCanIcon = (props: TrashCanProps) => { diff --git a/src2/frontend/assets/icons/interface/Video.tsx b/src/frontend/assets/icons/interface/Video.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Video.tsx rename to src/frontend/assets/icons/interface/Video.tsx index af31e2bcf..d77c11a5b 100644 --- a/src2/frontend/assets/icons/interface/Video.tsx +++ b/src/frontend/assets/icons/interface/Video.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type IVideoIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/interface/View.tsx b/src/frontend/assets/icons/interface/View.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/View.tsx rename to src/frontend/assets/icons/interface/View.tsx index f2125d77a..b32bcb90e 100644 --- a/src2/frontend/assets/icons/interface/View.tsx +++ b/src/frontend/assets/icons/interface/View.tsx @@ -1,7 +1,6 @@ +import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' - import { IIconProps } from '../Types/iconTypes' -import { IconStyles } from '../../../data/constants/icon-styles' export default function ViewIcon(props: IIconProps) { const { stroke, className, size = 'sm', ...res } = props diff --git a/src2/frontend/assets/icons/interface/ViewHidden.tsx b/src/frontend/assets/icons/interface/ViewHidden.tsx similarity index 87% rename from src2/frontend/assets/icons/interface/ViewHidden.tsx rename to src/frontend/assets/icons/interface/ViewHidden.tsx index d2368062c..d121486ac 100644 --- a/src2/frontend/assets/icons/interface/ViewHidden.tsx +++ b/src/frontend/assets/icons/interface/ViewHidden.tsx @@ -1,7 +1,6 @@ +import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' - import { IIconProps } from '../Types/iconTypes' -import { IconStyles } from '../../../data/constants/icon-styles' export default function ViewHiddenIcon(props: IIconProps) { const { stroke, className, size = 'sm', ...res } = props @@ -23,15 +22,7 @@ export default function ViewHiddenIcon(props: IIconProps) { fill={stroke || '#0464FB'} /> {/* Diagonal line through the eye */} - + ) } diff --git a/src2/frontend/assets/icons/interface/Warning.tsx b/src/frontend/assets/icons/interface/Warning.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Warning.tsx rename to src/frontend/assets/icons/interface/Warning.tsx index 5058c303d..fd80a0abf 100644 --- a/src2/frontend/assets/icons/interface/Warning.tsx +++ b/src/frontend/assets/icons/interface/Warning.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentPropsWithRef, ElementType } from 'react' +import { cn } from '../../../utils/cn' + type IWarningIconProps = ComponentPropsWithRef<'svg'> & { variant?: 'default' | 'primary' | 'secondary' direction?: 'up' | 'down' | 'left' | 'right' diff --git a/src2/frontend/assets/icons/interface/Zap.tsx b/src/frontend/assets/icons/interface/Zap.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/Zap.tsx rename to src/frontend/assets/icons/interface/Zap.tsx index 73a708241..5e2c06b32 100644 --- a/src2/frontend/assets/icons/interface/Zap.tsx +++ b/src/frontend/assets/icons/interface/Zap.tsx @@ -1,7 +1,6 @@ +import { IconStyles } from '../../../data/constants/icon-styles' import { cn } from '../../../utils/cn' - import { IIconProps } from '../Types/iconTypes' -import { IconStyles } from '../../../data/constants/icon-styles' export default function ZapIcon(props: IIconProps) { const { className, size = 'sm', ...res } = props diff --git a/src2/frontend/assets/icons/interface/ZoomInOut.tsx b/src/frontend/assets/icons/interface/ZoomInOut.tsx similarity index 99% rename from src2/frontend/assets/icons/interface/ZoomInOut.tsx rename to src/frontend/assets/icons/interface/ZoomInOut.tsx index a8cd056a3..16dfc1c6e 100644 --- a/src2/frontend/assets/icons/interface/ZoomInOut.tsx +++ b/src/frontend/assets/icons/interface/ZoomInOut.tsx @@ -1,6 +1,5 @@ -import { cn } from '../../../utils/cn' - import { IconStyles } from '../../../data/constants/icon-styles' +import { cn } from '../../../utils/cn' import { IIconProps } from '../Types/iconTypes' export const ZoomInOut = (props: IIconProps) => { diff --git a/src2/frontend/assets/icons/library/CloseFolder.tsx b/src/frontend/assets/icons/library/CloseFolder.tsx similarity index 99% rename from src2/frontend/assets/icons/library/CloseFolder.tsx rename to src/frontend/assets/icons/library/CloseFolder.tsx index 99b9339b1..e7343d789 100644 --- a/src2/frontend/assets/icons/library/CloseFolder.tsx +++ b/src/frontend/assets/icons/library/CloseFolder.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type ILibraryCloseFolderIconProps = ComponentProps<'svg'> & { size: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/library/File.tsx b/src/frontend/assets/icons/library/File.tsx similarity index 99% rename from src2/frontend/assets/icons/library/File.tsx rename to src/frontend/assets/icons/library/File.tsx index ee402ba99..480e4c7d7 100644 --- a/src2/frontend/assets/icons/library/File.tsx +++ b/src/frontend/assets/icons/library/File.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type ILibraryFileIconProps = ComponentProps<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/library/OpenFolder.tsx b/src/frontend/assets/icons/library/OpenFolder.tsx similarity index 99% rename from src2/frontend/assets/icons/library/OpenFolder.tsx rename to src/frontend/assets/icons/library/OpenFolder.tsx index 10c110177..6f069a945 100644 --- a/src2/frontend/assets/icons/library/OpenFolder.tsx +++ b/src/frontend/assets/icons/library/OpenFolder.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../utils/cn' import { ComponentProps } from 'react' +import { cn } from '../../../utils/cn' + type ILibraryOpenFolderIconProps = ComponentProps<'svg'> & { size: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/oplc.tsx b/src/frontend/assets/icons/oplc.tsx similarity index 99% rename from src2/frontend/assets/icons/oplc.tsx rename to src/frontend/assets/icons/oplc.tsx index 401930d24..5bfa46592 100644 --- a/src2/frontend/assets/icons/oplc.tsx +++ b/src/frontend/assets/icons/oplc.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../utils/cn' type IOpenPLCIconProps = ComponentProps<'svg'> diff --git a/src2/frontend/assets/icons/project/Array.tsx b/src/frontend/assets/icons/project/Array.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Array.tsx rename to src/frontend/assets/icons/project/Array.tsx index 241ae6867..e17f813c3 100644 --- a/src2/frontend/assets/icons/project/Array.tsx +++ b/src/frontend/assets/icons/project/Array.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type ArrayIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Block.tsx b/src/frontend/assets/icons/project/Block.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Block.tsx rename to src/frontend/assets/icons/project/Block.tsx index f4f80030c..e5d71ce22 100644 --- a/src2/frontend/assets/icons/project/Block.tsx +++ b/src/frontend/assets/icons/project/Block.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type IBlockIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/CExt.tsx b/src/frontend/assets/icons/project/CExt.tsx similarity index 99% rename from src2/frontend/assets/icons/project/CExt.tsx rename to src/frontend/assets/icons/project/CExt.tsx index 90936aa0f..544c9dcd5 100644 --- a/src2/frontend/assets/icons/project/CExt.tsx +++ b/src/frontend/assets/icons/project/CExt.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type ICExtIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Coil.tsx b/src/frontend/assets/icons/project/Coil.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Coil.tsx rename to src/frontend/assets/icons/project/Coil.tsx index 6bfc21db4..0e1efd390 100644 --- a/src2/frontend/assets/icons/project/Coil.tsx +++ b/src/frontend/assets/icons/project/Coil.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type ICoilIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' diff --git a/src2/frontend/assets/icons/project/Contact.tsx b/src/frontend/assets/icons/project/Contact.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Contact.tsx rename to src/frontend/assets/icons/project/Contact.tsx index fd68c4bc0..9246bf969 100644 --- a/src2/frontend/assets/icons/project/Contact.tsx +++ b/src/frontend/assets/icons/project/Contact.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type IContactIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' diff --git a/src2/frontend/assets/icons/project/Cpp.tsx b/src/frontend/assets/icons/project/Cpp.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Cpp.tsx rename to src/frontend/assets/icons/project/Cpp.tsx index b8ee83982..c8f9fb06e 100644 --- a/src2/frontend/assets/icons/project/Cpp.tsx +++ b/src/frontend/assets/icons/project/Cpp.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type CppIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/DataType.tsx b/src/frontend/assets/icons/project/DataType.tsx similarity index 99% rename from src2/frontend/assets/icons/project/DataType.tsx rename to src/frontend/assets/icons/project/DataType.tsx index 2f0711c7b..02c103428 100644 --- a/src2/frontend/assets/icons/project/DataType.tsx +++ b/src/frontend/assets/icons/project/DataType.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type IDataTypeIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Device.tsx b/src/frontend/assets/icons/project/Device.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Device.tsx rename to src/frontend/assets/icons/project/Device.tsx index bd83078b1..9447db6ab 100644 --- a/src2/frontend/assets/icons/project/Device.tsx +++ b/src/frontend/assets/icons/project/Device.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IDeviceIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Enum.tsx b/src/frontend/assets/icons/project/Enum.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Enum.tsx rename to src/frontend/assets/icons/project/Enum.tsx index 0a6e2f379..4afa0b788 100644 --- a/src2/frontend/assets/icons/project/Enum.tsx +++ b/src/frontend/assets/icons/project/Enum.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type EnumIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/FBD.tsx b/src/frontend/assets/icons/project/FBD.tsx similarity index 99% rename from src2/frontend/assets/icons/project/FBD.tsx rename to src/frontend/assets/icons/project/FBD.tsx index 5e51b9743..75bbed2e0 100644 --- a/src2/frontend/assets/icons/project/FBD.tsx +++ b/src/frontend/assets/icons/project/FBD.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IFBDIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Function.tsx b/src/frontend/assets/icons/project/Function.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Function.tsx rename to src/frontend/assets/icons/project/Function.tsx index cca5d1e55..932962025 100644 --- a/src2/frontend/assets/icons/project/Function.tsx +++ b/src/frontend/assets/icons/project/Function.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IFunctionIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/FunctionBlock.tsx b/src/frontend/assets/icons/project/FunctionBlock.tsx similarity index 99% rename from src2/frontend/assets/icons/project/FunctionBlock.tsx rename to src/frontend/assets/icons/project/FunctionBlock.tsx index afb7eb76f..dbddd737f 100644 --- a/src2/frontend/assets/icons/project/FunctionBlock.tsx +++ b/src/frontend/assets/icons/project/FunctionBlock.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IFunctionBlockIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/GenericDT.tsx b/src/frontend/assets/icons/project/GenericDT.tsx similarity index 99% rename from src2/frontend/assets/icons/project/GenericDT.tsx rename to src/frontend/assets/icons/project/GenericDT.tsx index ace1dde95..538ee234b 100644 --- a/src2/frontend/assets/icons/project/GenericDT.tsx +++ b/src/frontend/assets/icons/project/GenericDT.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type IGenericIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/IL.tsx b/src/frontend/assets/icons/project/IL.tsx similarity index 99% rename from src2/frontend/assets/icons/project/IL.tsx rename to src/frontend/assets/icons/project/IL.tsx index 2c529d956..ea0baa6cf 100644 --- a/src2/frontend/assets/icons/project/IL.tsx +++ b/src/frontend/assets/icons/project/IL.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IILIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/LD.tsx b/src/frontend/assets/icons/project/LD.tsx similarity index 99% rename from src2/frontend/assets/icons/project/LD.tsx rename to src/frontend/assets/icons/project/LD.tsx index eb4bf48eb..7d6fea3c1 100644 --- a/src2/frontend/assets/icons/project/LD.tsx +++ b/src/frontend/assets/icons/project/LD.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type ILDIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Loop.tsx b/src/frontend/assets/icons/project/Loop.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Loop.tsx rename to src/frontend/assets/icons/project/Loop.tsx index 2ba98f075..95a94bbd5 100644 --- a/src2/frontend/assets/icons/project/Loop.tsx +++ b/src/frontend/assets/icons/project/Loop.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type ILoopIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Orchestrator.tsx b/src/frontend/assets/icons/project/Orchestrator.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Orchestrator.tsx rename to src/frontend/assets/icons/project/Orchestrator.tsx index f64e9fa22..cd253cd08 100644 --- a/src2/frontend/assets/icons/project/Orchestrator.tsx +++ b/src/frontend/assets/icons/project/Orchestrator.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IOrchestratorIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/PLC.tsx b/src/frontend/assets/icons/project/PLC.tsx similarity index 99% rename from src2/frontend/assets/icons/project/PLC.tsx rename to src/frontend/assets/icons/project/PLC.tsx index e7684f62c..51565cad9 100644 --- a/src2/frontend/assets/icons/project/PLC.tsx +++ b/src/frontend/assets/icons/project/PLC.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IPLCIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Program.tsx b/src/frontend/assets/icons/project/Program.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Program.tsx rename to src/frontend/assets/icons/project/Program.tsx index 9228b4db2..a9ed063a2 100644 --- a/src2/frontend/assets/icons/project/Program.tsx +++ b/src/frontend/assets/icons/project/Program.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IProgramIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Python.tsx b/src/frontend/assets/icons/project/Python.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Python.tsx rename to src/frontend/assets/icons/project/Python.tsx index dbd2dd984..527baa62a 100644 --- a/src2/frontend/assets/icons/project/Python.tsx +++ b/src/frontend/assets/icons/project/Python.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type PythonIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/RemoteDevice.tsx b/src/frontend/assets/icons/project/RemoteDevice.tsx similarity index 91% rename from src2/frontend/assets/icons/project/RemoteDevice.tsx rename to src/frontend/assets/icons/project/RemoteDevice.tsx index a312e7b9e..41747467b 100644 --- a/src2/frontend/assets/icons/project/RemoteDevice.tsx +++ b/src/frontend/assets/icons/project/RemoteDevice.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IRemoteDeviceIconProps = ComponentProps<'svg'> & { @@ -32,12 +33,7 @@ export const RemoteDeviceIcon = (props: IRemoteDeviceIconProps) => { fill='#B4D0FE' /> - + ) } diff --git a/src2/frontend/assets/icons/project/Resource.tsx b/src/frontend/assets/icons/project/Resource.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Resource.tsx rename to src/frontend/assets/icons/project/Resource.tsx index 976960b40..89faadf55 100644 --- a/src2/frontend/assets/icons/project/Resource.tsx +++ b/src/frontend/assets/icons/project/Resource.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IResourceIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/SFC.tsx b/src/frontend/assets/icons/project/SFC.tsx similarity index 99% rename from src2/frontend/assets/icons/project/SFC.tsx rename to src/frontend/assets/icons/project/SFC.tsx index 125b1ab97..6ad4cb0c2 100644 --- a/src2/frontend/assets/icons/project/SFC.tsx +++ b/src/frontend/assets/icons/project/SFC.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type ISFCIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/ST.tsx b/src/frontend/assets/icons/project/ST.tsx similarity index 99% rename from src2/frontend/assets/icons/project/ST.tsx rename to src/frontend/assets/icons/project/ST.tsx index 3d1974de9..637a61b50 100644 --- a/src2/frontend/assets/icons/project/ST.tsx +++ b/src/frontend/assets/icons/project/ST.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type ISTIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Server.tsx b/src/frontend/assets/icons/project/Server.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Server.tsx rename to src/frontend/assets/icons/project/Server.tsx index acf06de25..57fed64a2 100644 --- a/src2/frontend/assets/icons/project/Server.tsx +++ b/src/frontend/assets/icons/project/Server.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IServerIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/ServersFolder.tsx b/src/frontend/assets/icons/project/ServersFolder.tsx similarity index 99% rename from src2/frontend/assets/icons/project/ServersFolder.tsx rename to src/frontend/assets/icons/project/ServersFolder.tsx index 852a86a6f..29346693d 100644 --- a/src2/frontend/assets/icons/project/ServersFolder.tsx +++ b/src/frontend/assets/icons/project/ServersFolder.tsx @@ -1,4 +1,5 @@ import { ComponentProps } from 'react' + import { cn } from '../../../utils/cn' type IServersFolderIconProps = ComponentProps<'svg'> & { diff --git a/src2/frontend/assets/icons/project/Structure.tsx b/src/frontend/assets/icons/project/Structure.tsx similarity index 99% rename from src2/frontend/assets/icons/project/Structure.tsx rename to src/frontend/assets/icons/project/Structure.tsx index b551dd862..78a351bf2 100644 --- a/src2/frontend/assets/icons/project/Structure.tsx +++ b/src/frontend/assets/icons/project/Structure.tsx @@ -1,4 +1,5 @@ import { ComponentPropsWithoutRef } from 'react' + import { cn } from '../../../utils/cn' type StructureIconProps = ComponentPropsWithoutRef<'svg'> & { diff --git a/src2/frontend/assets/icons/project/fbd/Block.tsx b/src/frontend/assets/icons/project/fbd/Block.tsx similarity index 79% rename from src2/frontend/assets/icons/project/fbd/Block.tsx rename to src/frontend/assets/icons/project/fbd/Block.tsx index 89d31ee23..472cdfb52 100644 --- a/src2/frontend/assets/icons/project/fbd/Block.tsx +++ b/src/frontend/assets/icons/project/fbd/Block.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../../utils/cn' + type IBlockIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } @@ -39,24 +40,8 @@ export default function BlockIcon(props: IBlockIconProps) { fill='#B4D0FE' fill-opacity='0.5' /> - - + + & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/project/fbd/Connector.tsx b/src/frontend/assets/icons/project/fbd/Connector.tsx similarity index 99% rename from src2/frontend/assets/icons/project/fbd/Connector.tsx rename to src/frontend/assets/icons/project/fbd/Connector.tsx index f67bfe136..2b8732dda 100644 --- a/src2/frontend/assets/icons/project/fbd/Connector.tsx +++ b/src/frontend/assets/icons/project/fbd/Connector.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../../utils/cn' + type IBlockIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/project/fbd/Continuation.tsx b/src/frontend/assets/icons/project/fbd/Continuation.tsx similarity index 99% rename from src2/frontend/assets/icons/project/fbd/Continuation.tsx rename to src/frontend/assets/icons/project/fbd/Continuation.tsx index 428b7560b..19dc29da2 100644 --- a/src2/frontend/assets/icons/project/fbd/Continuation.tsx +++ b/src/frontend/assets/icons/project/fbd/Continuation.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../../utils/cn' + type IBlockIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/project/fbd/VariableIn.tsx b/src/frontend/assets/icons/project/fbd/VariableIn.tsx similarity index 99% rename from src2/frontend/assets/icons/project/fbd/VariableIn.tsx rename to src/frontend/assets/icons/project/fbd/VariableIn.tsx index 92eaeceaf..d97ce93bd 100644 --- a/src2/frontend/assets/icons/project/fbd/VariableIn.tsx +++ b/src/frontend/assets/icons/project/fbd/VariableIn.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../../utils/cn' + type IBlockIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src2/frontend/assets/icons/project/fbd/VariableInOut.tsx b/src/frontend/assets/icons/project/fbd/VariableInOut.tsx similarity index 86% rename from src2/frontend/assets/icons/project/fbd/VariableInOut.tsx rename to src/frontend/assets/icons/project/fbd/VariableInOut.tsx index d152db399..70be2cf05 100644 --- a/src2/frontend/assets/icons/project/fbd/VariableInOut.tsx +++ b/src/frontend/assets/icons/project/fbd/VariableInOut.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../../utils/cn' + type IBlockIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } @@ -44,15 +45,7 @@ export default function VariableInOutIcon(props: IBlockIconProps) { stroke-opacity='0.5' strokeWidth='1.22727' /> - + ) } diff --git a/src2/frontend/assets/icons/project/fbd/VariableOut.tsx b/src/frontend/assets/icons/project/fbd/VariableOut.tsx similarity index 84% rename from src2/frontend/assets/icons/project/fbd/VariableOut.tsx rename to src/frontend/assets/icons/project/fbd/VariableOut.tsx index 664cd6b5e..559f90ce0 100644 --- a/src2/frontend/assets/icons/project/fbd/VariableOut.tsx +++ b/src/frontend/assets/icons/project/fbd/VariableOut.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../../utils/cn' + type IBlockIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } @@ -35,15 +36,7 @@ export default function VariableOutIcon(props: IBlockIconProps) { stroke='#B4D0FE' strokeWidth='1.22727' /> - + ) } diff --git a/src/frontend/assets/icons/project/index.ts b/src/frontend/assets/icons/project/index.ts new file mode 100644 index 000000000..97fb540ed --- /dev/null +++ b/src/frontend/assets/icons/project/index.ts @@ -0,0 +1,3 @@ +export { ArrayIcon } from './Array' +export { EnumIcon } from './Enum' +export { StructureIcon } from './Structure' diff --git a/src2/frontend/assets/icons/project/ladder/Block.tsx b/src/frontend/assets/icons/project/ladder/Block.tsx similarity index 80% rename from src2/frontend/assets/icons/project/ladder/Block.tsx rename to src/frontend/assets/icons/project/ladder/Block.tsx index babb5eead..b2dbc1a79 100644 --- a/src2/frontend/assets/icons/project/ladder/Block.tsx +++ b/src/frontend/assets/icons/project/ladder/Block.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' +import { cn } from '../../../../utils/cn' + type IBlockIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } @@ -39,24 +40,8 @@ export default function BlockIcon(props: IBlockIconProps) { fill='#B4D0FE' fillOpacity='0.5' /> - - + + & { + size?: 'sm' | 'md' | 'lg' +} +const sizeClasses = { + sm: 'w-5 h-5', + md: 'w-6 h-6', + lg: 'w-12 h-12', +} + +export default function CoilIcon(props: ICoilIconProps) { + const { className, size = 'sm', ...res } = props + + return ( + + + + + + + + ) +} diff --git a/src2/frontend/assets/icons/project/ladder/Contact.tsx b/src/frontend/assets/icons/project/ladder/Contact.tsx similarity index 99% rename from src2/frontend/assets/icons/project/ladder/Contact.tsx rename to src/frontend/assets/icons/project/ladder/Contact.tsx index 03468924e..aa74edbac 100644 --- a/src2/frontend/assets/icons/project/ladder/Contact.tsx +++ b/src/frontend/assets/icons/project/ladder/Contact.tsx @@ -1,5 +1,6 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef } from 'react' + +import { cn } from '../../../../utils/cn' type IContactIconProps = ComponentPropsWithoutRef<'svg'> & { size?: 'sm' | 'md' | 'lg' } diff --git a/src/renderer/assets/icons/window-controls/Close.tsx b/src/frontend/assets/icons/window-controls/Close.tsx similarity index 100% rename from src/renderer/assets/icons/window-controls/Close.tsx rename to src/frontend/assets/icons/window-controls/Close.tsx diff --git a/src/renderer/assets/icons/window-controls/Exit-Maximize.tsx b/src/frontend/assets/icons/window-controls/Exit-Maximize.tsx similarity index 100% rename from src/renderer/assets/icons/window-controls/Exit-Maximize.tsx rename to src/frontend/assets/icons/window-controls/Exit-Maximize.tsx diff --git a/src/renderer/assets/icons/window-controls/Maximize.tsx b/src/frontend/assets/icons/window-controls/Maximize.tsx similarity index 100% rename from src/renderer/assets/icons/window-controls/Maximize.tsx rename to src/frontend/assets/icons/window-controls/Maximize.tsx diff --git a/src/renderer/assets/icons/window-controls/Minimize.tsx b/src/frontend/assets/icons/window-controls/Minimize.tsx similarity index 100% rename from src/renderer/assets/icons/window-controls/Minimize.tsx rename to src/frontend/assets/icons/window-controls/Minimize.tsx diff --git a/src/renderer/assets/images/example.png b/src/frontend/assets/images/example.png similarity index 100% rename from src/renderer/assets/images/example.png rename to src/frontend/assets/images/example.png diff --git a/src2/frontend/components/_atoms/.gitkeep b/src/frontend/components/_atoms/.gitkeep similarity index 100% rename from src2/frontend/components/_atoms/.gitkeep rename to src/frontend/components/_atoms/.gitkeep diff --git a/src2/frontend/components/_atoms/accordion/index.tsx b/src/frontend/components/_atoms/accordion/index.tsx similarity index 99% rename from src2/frontend/components/_atoms/accordion/index.tsx rename to src/frontend/components/_atoms/accordion/index.tsx index aa4250430..881d50e6e 100644 --- a/src2/frontend/components/_atoms/accordion/index.tsx +++ b/src/frontend/components/_atoms/accordion/index.tsx @@ -1,8 +1,9 @@ import * as AccordionPrimitive from '@radix-ui/react-accordion' import { ChevronDownIcon } from '@radix-ui/react-icons' -import { cn } from '../../../utils/cn' import { forwardRef, useEffect, useRef, useState } from 'react' +import { cn } from '../../../utils/cn' + interface AccordionItemProps { title: React.ReactNode content: React.ReactNode diff --git a/src2/frontend/components/_atoms/buttons/activity-bar/index.tsx b/src/frontend/components/_atoms/buttons/activity-bar/index.tsx similarity index 75% rename from src2/frontend/components/_atoms/buttons/activity-bar/index.tsx rename to src/frontend/components/_atoms/buttons/activity-bar/index.tsx index e9f787eae..0e31231fd 100644 --- a/src2/frontend/components/_atoms/buttons/activity-bar/index.tsx +++ b/src/frontend/components/_atoms/buttons/activity-bar/index.tsx @@ -1,16 +1,18 @@ +import { ComponentPropsWithoutRef, forwardRef } from 'react' + import { cn } from '../../../../utils/cn' -import { ComponentPropsWithoutRef } from 'react' type IActivityBarButtonProps = ComponentPropsWithoutRef<'button'> & { 'data-active'?: string } -const ActivityBarButton = (props: IActivityBarButtonProps) => { +const ActivityBarButton = forwardRef((props, ref) => { const { children, className, 'data-active': dataActive, ...res } = props const isActive = dataActive === 'true' return ( ) -} +}) +ActivityBarButton.displayName = 'ActivityBarButton' export { ActivityBarButton } diff --git a/src2/frontend/components/_atoms/buttons/console/clear-console.tsx b/src/frontend/components/_atoms/buttons/console/clear-console.tsx similarity index 100% rename from src2/frontend/components/_atoms/buttons/console/clear-console.tsx rename to src/frontend/components/_atoms/buttons/console/clear-console.tsx diff --git a/src2/frontend/components/_atoms/buttons/default/index.tsx b/src/frontend/components/_atoms/buttons/default/index.tsx similarity index 99% rename from src2/frontend/components/_atoms/buttons/default/index.tsx rename to src/frontend/components/_atoms/buttons/default/index.tsx index 7500ecb69..32bb84f8e 100644 --- a/src2/frontend/components/_atoms/buttons/default/index.tsx +++ b/src/frontend/components/_atoms/buttons/default/index.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithRef, ReactNode } from 'react' +import { cn } from '../../../../utils/cn' + type ButtonProps = ComponentPropsWithRef<'button'> & { ghosted?: boolean children?: ReactNode diff --git a/src2/frontend/components/_atoms/buttons/tables-actions/index.tsx b/src/frontend/components/_atoms/buttons/tables-actions/index.tsx similarity index 99% rename from src2/frontend/components/_atoms/buttons/tables-actions/index.tsx rename to src/frontend/components/_atoms/buttons/tables-actions/index.tsx index d52184716..d02034934 100644 --- a/src2/frontend/components/_atoms/buttons/tables-actions/index.tsx +++ b/src/frontend/components/_atoms/buttons/tables-actions/index.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithoutRef, ReactNode } from 'react' +import { cn } from '../../../../utils/cn' + type TableActionButtonProps = ComponentPropsWithoutRef<'button'> & { children: ReactNode className?: string diff --git a/src2/frontend/components/_atoms/buttons/window-control/index.tsx b/src/frontend/components/_atoms/buttons/window-control/index.tsx similarity index 99% rename from src2/frontend/components/_atoms/buttons/window-control/index.tsx rename to src/frontend/components/_atoms/buttons/window-control/index.tsx index 49e347f9b..f49f4d25f 100644 --- a/src2/frontend/components/_atoms/buttons/window-control/index.tsx +++ b/src/frontend/components/_atoms/buttons/window-control/index.tsx @@ -1,6 +1,7 @@ -import { cn } from '../../../../utils/cn' import { ComponentPropsWithRef } from 'react' +import { cn } from '../../../../utils/cn' + type WindowControlButtonProps = ComponentPropsWithRef<'button'> const WindowControlButton = (props: WindowControlButtonProps) => { diff --git a/src/renderer/components/_atoms/card/index.tsx b/src/frontend/components/_atoms/card/index.tsx similarity index 100% rename from src/renderer/components/_atoms/card/index.tsx rename to src/frontend/components/_atoms/card/index.tsx diff --git a/src2/frontend/components/_atoms/checkbox/index.tsx b/src/frontend/components/_atoms/checkbox/index.tsx similarity index 88% rename from src2/frontend/components/_atoms/checkbox/index.tsx rename to src/frontend/components/_atoms/checkbox/index.tsx index 08937c7e6..38330435c 100644 --- a/src2/frontend/components/_atoms/checkbox/index.tsx +++ b/src/frontend/components/_atoms/checkbox/index.tsx @@ -1,6 +1,7 @@ import type { CheckboxProps as PrimitiveCheckboxProps } from '@radix-ui/react-checkbox' import * as PrimitiveCheckbox from '@radix-ui/react-checkbox' import { CheckIcon } from '@radix-ui/react-icons' + import { cn } from '../../../utils/cn' type CheckboxProps = PrimitiveCheckboxProps & { @@ -25,10 +26,7 @@ const Checkbox = ({ label, disabled, checked, className, ...props }: CheckboxPro {label && ( -