From 9cbf4ac7e95c21d7921701986499191d40ab4f76 Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 4 Aug 2026 13:12:35 +0900 Subject: [PATCH] version up! --- package.json | 2 +- src/main/index.ts | 280 +++++++++++++++++- src/preload/index.ts | 2 + src/renderer/public/slam-viewer/index.html | 3 +- src/renderer/src/App.tsx | 182 ++++++++++-- .../src/components/AppStatusModals.tsx | 43 ++- src/renderer/src/env.d.ts | 2 + .../features/curriculum/LearningInfoCard.tsx | 7 +- src/renderer/src/features/curriculum/types.ts | 2 + .../src/features/workspace/ModuleListRow.tsx | 10 +- .../src/features/workspace/ModuleListView.tsx | 131 +++++++- .../workspace/WorkspaceGuidePanel.tsx | 4 +- src/renderer/src/features/workspace/types.ts | 4 + .../workspace/useWorkspaceModuleControl.ts | 25 +- .../features/workspace/workspaceModules.ts | 4 + 15 files changed, 662 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 9d7ae7a..f890fd8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.4", + "version": "1.1.5", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/src/main/index.ts b/src/main/index.ts index a236243..d495c7e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -121,6 +121,7 @@ type CachedModuleFiles = { } type CachedModuleInfo = { + id?: string state: 'running' | 'stopped' | 'uninstalled' sortOrder: number category: string @@ -131,6 +132,9 @@ type CachedModuleInfo = { templateRepositoryUrl?: string | null sourceType?: string | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null + dockerImage?: string | null + packageName?: string | null + launchFile?: string | null parameters: string[] cpu: number | null memory: number | null @@ -138,6 +142,7 @@ type CachedModuleInfo = { } type CourseModuleInput = { + id?: string sortOrder?: number | null category: string name: string @@ -145,6 +150,18 @@ type CourseModuleInput = { templateRepositoryUrl?: string | null sourceType?: string | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null + dockerImage?: string | null + packageName?: string | null + launchFile?: string | null +} + +type CustomModuleScaffoldInput = { + name: string + category: string + folderName: string + dockerImage?: string | null + packageName: string + launchFile: string } type ModulePrebuiltArtifact = { @@ -364,6 +381,173 @@ function isGazeboNav2ModuleFolder(folderName?: string | null): boolean { return /^\[lms-nav2-gazebo-[^\]]+\].+_v[^/]+_ws$/.test(folderName?.trim() ?? '') } +function isCustomNav2ModuleFolder(folderName?: string | null): boolean { + return /^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/]+_ws$/.test(folderName?.trim() ?? '') +} + +function validateCustomModuleScaffold(input: CustomModuleScaffoldInput): CustomModuleScaffoldInput { + const name = input.name?.trim() + const category = input.category?.trim() + const folderName = input.folderName?.trim() + const packageName = input.packageName?.trim() + const launchFile = input.launchFile?.trim() + const dockerImage = input.dockerImage?.trim() || null + if (!name || !category || !folderName || !packageName || !launchFile) throw new Error('커스텀 모듈 정보가 올바르지 않습니다.') + if (!isCustomNav2ModuleFolder(folderName) || !/^[a-z][a-z0-9_]*$/.test(packageName) || !/^[a-zA-Z0-9_.-]+\.launch\.py$/.test(launchFile)) { + throw new Error('커스텀 모듈의 패키지명 또는 Launch 파일명이 올바르지 않습니다.') + } + if (dockerImage && (!/^[a-zA-Z0-9_./:@-]+$/.test(dockerImage) || dockerImage.length > 180)) { + throw new Error('실행 이미지명이 올바르지 않습니다.') + } + return { name, category, folderName, packageName, launchFile, dockerImage } +} + +function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record { + const module = validateCustomModuleScaffold(input) + const executable = `${module.packageName}_node` + const nodePath = `${module.packageName}/node.py` + return { + '.autonomy-studio-module.json': `${JSON.stringify({ + sourceType: 'CUSTOM', + name: module.name, + category: module.category, + dockerImage: module.dockerImage, + packageName: module.packageName, + launchFile: module.launchFile, + }, null, 2)}\n`, + '.gitignore': 'build/\ninstall/\nlog/\n__pycache__/\n*.py[cod]\n', + 'package.xml': [ + '', + '', + ` ${module.packageName}`, + ' 0.0.1', + ` ${module.name}`, + ' Autonomy Studio Student', + ' Apache-2.0', + ' ament_python', + ' rclpy', + ' launch', + ' launch_ros', + ' ament_python', + '', + '', + ].join('\n'), + 'setup.cfg': `[develop]\nscript_dir=$base/lib/${module.packageName}\n[install]\ninstall_scripts=$base/lib/${module.packageName}\n`, + 'setup.py': [ + 'from setuptools import setup', + '', + `package_name = '${module.packageName}'`, + '', + 'setup(', + ' name=package_name,', + " version='0.0.1',", + ` packages=['${module.packageName}'],`, + ` data_files=[('share/ament_index/resource_index/packages', ['resource/${module.packageName}']), ('share/${module.packageName}/launch', ['launch/${module.launchFile}']), ('share/${module.packageName}', ['package.xml'])],`, + " install_requires=['setuptools'],", + ' zip_safe=True,', + " maintainer='Autonomy Studio Student',", + " maintainer_email='student@autonomy-studio.local',", + ` description='${module.name}',`, + " license='Apache-2.0',", + ` entry_points={{'console_scripts': ['${executable} = ${module.packageName}.node:main']}},`, + ')', + '', + ].join('\n'), + [`resource/${module.packageName}`]: '', + [`${module.packageName}/__init__.py`]: '', + [nodePath]: [ + 'import rclpy', + 'from rclpy.node import Node', + '', + 'class CustomModuleNode(Node):', + ' def __init__(self):', + ` super().__init__('${module.packageName}')`, + ` self.get_logger().info('${module.name} started')`, + '', + 'def main(args=None):', + ' rclpy.init(args=args)', + ' node = CustomModuleNode()', + ' try:', + ' rclpy.spin(node)', + ' finally:', + ' node.destroy_node()', + ' rclpy.shutdown()', + '', + "if __name__ == '__main__':", + ' main()', + '', + ].join('\n'), + [`launch/${module.launchFile}`]: [ + 'from launch import LaunchDescription', + 'from launch_ros.actions import Node', + '', + 'def generate_launch_description():', + ' return LaunchDescription([', + ` Node(package='${module.packageName}', executable='${executable}', name='${module.packageName}', output='screen'),`, + ' ])', + '', + ].join('\n'), + } +} + +async function createCustomWorkspaceModule( + workspaceRoot: string | null | undefined, + input: CustomModuleScaffoldInput, +): Promise<{ ok: boolean; workspacePath: string }> { + const module = validateCustomModuleScaffold(input) + const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) + if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') + const moduleRoot = resolve(resolvedWorkspaceRoot, module.folderName) + const files = buildCustomModuleFiles(module) + await mkdir(moduleRoot, { recursive: true }) + for (const [relativePath, content] of Object.entries(files)) { + const targetPath = resolve(moduleRoot, relativePath) + if (targetPath !== moduleRoot && !targetPath.startsWith(`${moduleRoot}/`)) throw new Error('허용되지 않은 모듈 파일 경로입니다.') + await mkdir(dirname(targetPath), { recursive: true }) + await writeFile(targetPath, content, { encoding: 'utf8', flag: 'wx' }).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'EEXIST') throw error + }) + } + await runCommand('git', ['init', '--initial-branch=main'], moduleRoot, undefined, 15_000).catch(() => undefined) + return { ok: true, workspacePath: moduleRoot } +} + +async function createRemoteCustomWorkspaceModule( + targetInput: RemoteWorkspaceTarget, + input: CustomModuleScaffoldInput, +): Promise<{ ok: boolean; workspacePath: string }> { + const target = normalizeRemoteTarget(targetInput) + const module = validateCustomModuleScaffold(input) + const files = buildCustomModuleFiles(module) + const payload = encodeBase64(JSON.stringify({ folderName: module.folderName, files })) + const output = await runSshOutput(target, [ + 'set -e', + `ROOT=${quoteShellSingle(target.workspaceRoot)}`, + ...expandRemotePathVar('ROOT'), + `PAYLOAD=$(printf %s ${quoteShellSingle(payload)} | base64 -d)`, + 'python3 - "$ROOT" "$PAYLOAD" <<\'PY\'', + 'import json, os, sys', + 'root = os.path.abspath(os.path.expanduser(sys.argv[1]))', + 'payload = json.loads(sys.argv[2])', + 'folder = payload["folderName"]', + 'target = os.path.abspath(os.path.join(root, folder))', + 'if target == root or not target.startswith(root + os.sep): raise SystemExit("invalid module path")', + 'os.makedirs(target, exist_ok=True)', + 'for relative, content in payload["files"].items():', + ' path = os.path.abspath(os.path.join(target, relative))', + ' if not path.startswith(target + os.sep): raise SystemExit("invalid module file path")', + ' os.makedirs(os.path.dirname(path), exist_ok=True)', + ' try:', + ' with open(path, "x", encoding="utf-8") as fp: fp.write(content)', + ' except FileExistsError:', + ' pass', + 'print(target)', + 'PY', + `git -C "$ROOT/${module.folderName}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || git -C "$ROOT/${module.folderName}" init -b main >/dev/null`, + ].join('\n'), 30_000) + return { ok: true, workspacePath: output.trim().split('\n')[0] || remotePathJoin(target.workspaceRoot, module.folderName) } +} + function resolveNav2RuntimeImage(runtimeImages?: RuntimeImageArtifact[] | null, folderName?: string | null): string { const gazeboModule = isGazeboNav2ModuleFolder(folderName) const gazeboImage = runtimeImages @@ -384,6 +568,16 @@ function resolveNav2RuntimeImage(runtimeImages?: RuntimeImageArtifact[] | null, return nav2Image || nav2DockerImage } +async function resolveCustomModuleRuntimeImage(moduleRoot: string, fallbackImage: string): Promise { + try { + const config = JSON.parse(await readFile(resolve(moduleRoot, '.autonomy-studio-module.json'), 'utf8')) as { dockerImage?: unknown } + const image = typeof config.dockerImage === 'string' ? config.dockerImage.trim() : '' + return image && /^[a-zA-Z0-9_./:@-]+$/.test(image) ? image : fallbackImage + } catch { + return fallbackImage + } +} + async function resolveRemoteNav2RuntimeImage( runtimeImages?: RuntimeImageArtifact[] | null, ): Promise { @@ -398,6 +592,25 @@ async function resolveRemoteNav2RuntimeImage( throw new Error('원격 플랫폼 NAV2 런타임 이미지가 설정되지 않았습니다.') } +async function resolveRemoteCustomModuleRuntimeImage( + target: RemoteWorkspaceTarget, + folderName: string, + fallbackImage: string, +): Promise { + const output = await runSshOutput(target, [ + `ROOT=${quoteShellSingle(target.workspaceRoot)}`, + ...expandRemotePathVar('ROOT'), + `CONFIG="$ROOT/${folderName}/.autonomy-studio-module.json"`, + 'if [ -f "$CONFIG" ]; then python3 - "$CONFIG" <<\'PY\'', + 'import json, re, sys', + 'image = json.load(open(sys.argv[1], encoding="utf-8")).get("dockerImage")', + 'print(image.strip() if isinstance(image, str) and re.fullmatch(r"[A-Za-z0-9_./:@-]+", image.strip()) else "")', + 'PY', + 'fi', + ].join('\n'), 8_000).catch(() => '') + return output.trim() || fallbackImage +} + function isAllowedContainerName(containerKey: string): boolean { return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(containerKey) } @@ -492,6 +705,7 @@ async function collectAvailableModules( const rosParams = installed && workspacePath ? await readModuleRosParams(workspacePath) : '' return { + id: module.id, state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled', sortOrder: module.sortOrder, category, @@ -500,8 +714,11 @@ async function collectAvailableModules( sourcePath, workspacePath, templateRepositoryUrl: module.templateRepositoryUrl ?? null, - sourceType: module.sourceType ?? null, - prebuiltArtifacts: module.prebuiltArtifacts ?? null, + sourceType: module.sourceType ?? null, + prebuiltArtifacts: module.prebuiltArtifacts ?? null, + dockerImage: module.dockerImage ?? null, + packageName: module.packageName ?? null, + launchFile: module.launchFile ?? null, parameters: splitRosParams(rosParams), cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, @@ -567,6 +784,7 @@ async function collectRemoteAvailableModules( const dockerStat = dockerInfo ? dockerStats.get(dockerInfo.containerKey) : null return { + id: module.id, state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled', sortOrder: module.sortOrder, category: module.category || parsed.category, @@ -575,6 +793,11 @@ async function collectRemoteAvailableModules( sourcePath: module.templateRepositoryUrl ?? '', workspacePath: installed?.path ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null, + sourceType: module.sourceType ?? null, + prebuiltArtifacts: module.prebuiltArtifacts ?? null, + dockerImage: module.dockerImage ?? null, + packageName: module.packageName ?? null, + launchFile: module.launchFile ?? null, parameters: installed?.parameters ?? [], cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, @@ -2667,6 +2890,16 @@ function buildNav2DockerRunScript(): string { 'done', 'set -- "${filtered_args[@]}"', '', + 'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then', + ' MODULE_CONFIG="$MODULE_ROOT/$MODULE_FOLDER/.autonomy-studio-module.json"', + ' test -f "$MODULE_CONFIG" || { echo "Custom module configuration is missing: $MODULE_FOLDER" >&2; exit 64; }', + ' read -r PACKAGE LAUNCH < <(python3 - "$MODULE_CONFIG" <<\'PY\'', + 'import json, sys', + 'config = json.load(open(sys.argv[1], encoding="utf-8"))', + 'print(config["packageName"], config["launchFile"])', + 'PY', + ' )', + 'else', 'case "$MODULE_FOLDER" in', ' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;', @@ -2689,6 +2922,7 @@ function buildNav2DockerRunScript(): string { ' *nav2-system*) PACKAGE=nav2_turtlebot_system; LAUNCH=system.launch.py ;;', ' *) echo "Unknown Nav2 module: $MODULE_FOLDER" >&2; exit 64 ;;', 'esac', + 'fi', '', 'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* ]]; then', ' mkdir -p "$MODULE_ROOT/maps"', @@ -2742,6 +2976,15 @@ function buildNav2DockerBuildScript(): string { 'IMAGE=$3', 'ROS_DOMAIN_ID_VALUE=${4:-0}', '', + 'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then', + ' MODULE_CONFIG="$MODULE_ROOT/$MODULE_FOLDER/.autonomy-studio-module.json"', + ' test -f "$MODULE_CONFIG" || { echo "Custom module configuration is missing: $MODULE_FOLDER" >&2; exit 64; }', + ' TARGET=$(python3 - "$MODULE_CONFIG" <<\'PY\'', + 'import json, sys', + 'print(json.load(open(sys.argv[1], encoding="utf-8"))["packageName"])', + 'PY', + ' )', + 'else', 'case "$MODULE_FOLDER" in', ' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;', ' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;', @@ -2764,6 +3007,7 @@ function buildNav2DockerBuildScript(): string { ' *nav2-system*) TARGET=nav2_turtlebot_system ;;', ' *) echo "Unknown Nav2 module: $MODULE_FOLDER" >&2; exit 64 ;;', 'esac', + 'fi', '', 'MODULE_PATH="$MODULE_ROOT/$MODULE_FOLDER"', 'test -d "$MODULE_PATH/src" || { echo "Module source is missing: $MODULE_FOLDER" >&2; exit 1; }', @@ -3075,6 +3319,10 @@ async function startWorkspaceModule( await ensureNav2RuntimeAssets() const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const scriptPath = resolveNav2DockerRunScript() + const runtimeImage = await resolveCustomModuleRuntimeImage( + moduleRoot, + resolveNav2RuntimeImage(runtimeImages, targetFolderName), + ) await runCommand( 'bash', [ @@ -3082,7 +3330,7 @@ async function startWorkspaceModule( dockerInfo.containerKey, resolvedWorkspaceRoot, targetFolderName, - resolveNav2RuntimeImage(runtimeImages, targetFolderName), + runtimeImage, String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0', ...splitRosParams(rosParams) ], @@ -3149,13 +3397,17 @@ async function buildWorkspaceModule( await prepareRuntimeImages(runtimeImages) if (isNav2ModuleFolder(targetFolderName)) { await ensureNav2RuntimeAssets() + const runtimeImage = await resolveCustomModuleRuntimeImage( + moduleRoot, + resolveNav2RuntimeImage(runtimeImages, targetFolderName), + ) await runCommand( 'bash', [ resolveNav2DockerBuildScript(), resolvedWorkspaceRoot, targetFolderName, - resolveNav2RuntimeImage(runtimeImages, targetFolderName), + runtimeImage, String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0' ], undefined, @@ -3220,7 +3472,11 @@ async function startRemoteWorkspaceModule( const nav2Module = isNav2ModuleFolder(targetFolderName) const runtimeImage = nav2Module - ? await resolveRemoteNav2RuntimeImage(runtimeImages) + ? await resolveRemoteCustomModuleRuntimeImage( + target, + targetFolderName, + await resolveRemoteNav2RuntimeImage(runtimeImages), + ) : null const runtimeRoot = nav2Module ? await ensureRemoteNav2RuntimeAssets(target) @@ -3343,7 +3599,11 @@ async function buildRemoteWorkspaceModule( if (isNav2ModuleFolder(targetFolderName)) { const runtimeRoot = await ensureRemoteNav2RuntimeAssets(target) - const runtimeImage = await resolveRemoteNav2RuntimeImage(runtimeImages) + const runtimeImage = await resolveRemoteCustomModuleRuntimeImage( + target, + targetFolderName, + await resolveRemoteNav2RuntimeImage(runtimeImages), + ) await runSsh(target, [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, @@ -4231,6 +4491,10 @@ function registerLocalWorkspaceHandlers(): void { }) ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) + ipcMain.handle('local-workspace:modules:create-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleScaffoldInput | null) => { + if (!input) throw new Error('커스텀 모듈 정보가 없습니다.') + return createCustomWorkspaceModule(workspaceRoot, input) + }) ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages)) @@ -4262,6 +4526,10 @@ function registerLocalWorkspaceHandlers(): void { }) ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules)) ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) + ipcMain.handle('remote-workspace:modules:create-custom', (_event, target: RemoteWorkspaceTarget, input?: CustomModuleScaffoldInput | null) => { + if (!input) throw new Error('커스텀 모듈 정보가 없습니다.') + return createRemoteCustomWorkspaceModule(target, input) + }) ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName)) diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f7b5eb..e99e0ce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -170,6 +170,7 @@ contextBridge.exposeInMainWorld('localWorkspace', { modules: { list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules), install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), + createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input), delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName), start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages), @@ -236,6 +237,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', { modules: { list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules), install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options), + createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input), start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages), stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName), diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 9ed7cab..6608613 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -420,7 +420,8 @@ let remoteNavigationSplatScene = null; let remoteNavigationSplatTransform = null; let remoteNavigationSplatEnabled = false; let remoteNavigationSplatLoading = null; -const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.4822845855746674, 1.3107954323633903, -0.8021416967574936); +// 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다. +const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.7322845855746674, 1.3107954323633903, -0.8021416967574936); const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469); const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587); const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index dffcff1..40fe84c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,7 +7,7 @@ import { io } from 'socket.io-client' import type { Socket } from 'socket.io-client' import Sidebar from './components/Sidebar' import PlatformLoadingOverlay from './components/PlatformLoadingOverlay' -import { ControlExtensionConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals' +import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals' import TitleBar from './components/TitleBar' import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig' import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession' @@ -17,6 +17,7 @@ import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen' import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal' import MaterialsModal from './features/curriculum/MaterialsModal' import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi' +import type { CustomModuleInput } from './features/workspace/ModuleListView' import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types' import { getContextHelpSteps } from './features/help/contextHelp' import AiGuideView from './features/AiGuideView' @@ -391,6 +392,7 @@ function LocalPcWorkspaceMockup({ onRequestControlExtension, onTitlebarNotice, onRegisterStopRunningModules, + onRegisterRunningModuleNames, canReleaseControl, canExtendControl, controlEndsAt, @@ -421,6 +423,7 @@ function LocalPcWorkspaceMockup({ onRequestControlExtension: () => void onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void onRegisterStopRunningModules?: (handler: (() => Promise) | null) => void + onRegisterRunningModuleNames?: (handler: (() => string[]) | null) => void canReleaseControl: boolean canExtendControl: boolean controlEndsAt?: string | null @@ -818,13 +821,41 @@ function LocalPcWorkspaceMockup({ if (visualizationStack !== 'nav2') setVisualizationStack('nav2') if (nav2VisualizationMode !== 'navigation') setNav2VisualizationMode('navigation') }, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack]) - const runtimeModules = useMemo(() => resolveRuntimeModules( - curriculumWeek.modules, - remoteWorkspace - ? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase())) - : activeEnvironmentProfile, - remoteWorkspace ? 'platform' : 'local' - ), [activeEnvironmentProfile, curriculumWeek.modules, remoteWorkspace, visualizationProfiles]) + const [customModules, setCustomModules] = useState>(() => ( + (curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM') + )) + const [deletedCustomModuleIds, setDeletedCustomModuleIds] = useState>(() => new Set()) + useEffect(() => { + setCustomModules((current) => { + const fromCurriculum = (curriculumWeek.modules ?? []).filter((module) => ( + module.sourceType === 'CUSTOM' && !deletedCustomModuleIds.has(module.id) + )) + const byId = new Map(current.map((module) => [module.id, module])) + fromCurriculum.forEach((module) => byId.set(module.id, module)) + return Array.from(byId.values()) + }) + }, [curriculumWeek.modules, deletedCustomModuleIds]) + const curriculumModulesWithCustom = useMemo(() => { + const baseModules = (curriculumWeek.modules ?? []).filter((module) => ( + module.sourceType !== 'CUSTOM' || !deletedCustomModuleIds.has(module.id) + )) + const knownIds = new Set(baseModules.map((module) => module.id)) + return [...baseModules, ...customModules.filter((module) => !knownIds.has(module.id))] + }, [curriculumWeek.modules, customModules, deletedCustomModuleIds]) + const runtimeModules = useMemo(() => { + const resolvedModules = resolveRuntimeModules( + curriculumModulesWithCustom, + remoteWorkspace + ? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase())) + : activeEnvironmentProfile, + remoteWorkspace ? 'platform' : 'local' + ) ?? [] + const knownIds = new Set(resolvedModules.map((module) => module.id)) + return [ + ...resolvedModules, + ...customModules.filter((module) => !knownIds.has(module.id)), + ].sort((left, right) => left.sortOrder - right.sortOrder) + }, [activeEnvironmentProfile, curriculumModulesWithCustom, customModules, remoteWorkspace, visualizationProfiles]) const moduleRuntimeImages = useMemo(() => ( resolveModuleRuntimeImages(visualizationProfiles, remoteWorkspace, activeRuntimeImages) ), [activeRuntimeImages, remoteWorkspace, visualizationProfiles]) @@ -1875,6 +1906,12 @@ function LocalPcWorkspaceMockup({ }, onTitlebarNotice }) + useEffect(() => { + onRegisterRunningModuleNames?.(() => availableModules + .filter((module) => module.state === 'running') + .map((module) => module.name)) + return () => onRegisterRunningModuleNames?.(null) + }, [availableModules, onRegisterRunningModuleNames]) const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise => { if (map.custom && map.layout && !remoteWorkspace) { await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout) @@ -1921,6 +1958,16 @@ function LocalPcWorkspaceMockup({ } else { await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName) } + if (module.sourceType === 'CUSTOM') { + if (!module.id) throw new Error('커스텀 모듈 식별자가 없습니다.') + const response = await fetchWithFreshAuth( + `${LMS_API_BASE_URL}/curriculum/custom-modules/${encodeURIComponent(module.id)}?courseId=${encodeURIComponent(courseKey)}`, + { method: 'DELETE' }, + ) + if (!response.ok) throw new Error(await response.text()) + setCustomModules((modules) => modules.filter((item) => item.id !== module.id)) + setDeletedCustomModuleIds((ids) => new Set([...ids, module.id!])) + } if (options.deleteRepository) { const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/repositories`, { method: 'DELETE', @@ -2519,6 +2566,51 @@ function LocalPcWorkspaceMockup({ openEditorFile(editorPath) setActiveIdeActivity('workspace') } + const createCustomModule = async (input: CustomModuleInput): Promise => { + const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ courseId: courseKey, week: weekNumber, ...input }), + }) + const payload = await response.json().catch(() => null) as { + message?: string + id?: string + sortOrder?: number + category?: string + name?: string + folderName?: string + dockerImage?: string | null + packageName?: string + launchFile?: string + } | null + if (!response.ok || !payload?.id || !payload.folderName || !payload.name || !payload.category) { + throw new Error(payload?.message ?? '모듈을 추가하지 못했습니다.') + } + const customModule = { + id: payload.id, + sortOrder: payload.sortOrder ?? 10_000 + customModules.length, + category: payload.category, + name: payload.name, + folderName: payload.folderName, + templateRepositoryUrl: null, + sourceType: 'CUSTOM', + prebuiltArtifacts: [], + defaultParameters: [], + dockerImage: payload.dockerImage ?? null, + packageName: payload.packageName ?? input.packageName, + launchFile: payload.launchFile ?? input.launchFile, + } as const + if (remoteWorkspace) { + await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule) + } else { + await window.localWorkspace.modules.createCustom(clonePathPreview, customModule) + } + setCustomModules((current) => [ + ...current, + { ...customModule, sortOrder: payload.sortOrder ?? 10_000 + current.length }, + ]) + onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info') + } const controlEndTime = controlEndsAt ? new Date(controlEndsAt).getTime() : Number.NaN const controlRemainingMs = Number.isNaN(controlEndTime) ? null : Math.max(0, controlEndTime - controlCountdownNow) const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000) @@ -3067,6 +3159,7 @@ function LocalPcWorkspaceMockup({ onOpenWorldEditor={() => { setWorldMapSelectorOpen(true) }} + onCreateCustomModule={createCustomModule} /> )} @@ -3182,6 +3275,7 @@ export default function App(): React.JSX.Element { const previousWeekStateRef = useRef>({}) const controlExpiryHandledRef = useRef>(new Set()) const stopRunningWorkspaceModulesRef = useRef<(() => Promise) | null>(null) + const getRunningWorkspaceModuleNamesRef = useRef<(() => string[]) | null>(null) const restoredStudioSession = restoredStudioSessionRef.current const [curriculumWeeks, setCurriculumWeeks] = useState([]) const [courseKey, setCourseKey] = useState('default-course') @@ -3238,6 +3332,10 @@ export default function App(): React.JSX.Element { const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined) const [workspaceWeekState, setWorkspaceWeekState] = useState(null) const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null) + const [moduleShutdownConfirm, setModuleShutdownConfirm] = useState<{ + action: 'disconnect' | 'release' + moduleNames: string[] + } | null>(null) const [controlExtensionConfirmOpen, setControlExtensionConfirmOpen] = useState(false) const [controlExtensionSubmitting, setControlExtensionSubmitting] = useState(false) const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState(null) @@ -3999,6 +4097,11 @@ export default function App(): React.JSX.Element { setSessionConfirmAction(null) return } + if (moduleShutdownConfirm) { + event.preventDefault() + setModuleShutdownConfirm(null) + return + } if (runningPlatformCloseTarget) { event.preventDefault() setRunningPlatformCloseTarget(null) @@ -4053,7 +4156,7 @@ export default function App(): React.JSX.Element { window.addEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape) - }, [completedCurriculumNotice, controlWarningNotice, courseChangeBlockedOpen, instructorStudioModalOpen, materialModalOpen, platformModalMode, platformModalOpen, recentSessionModalOpen, runningPlatformCloseTarget, sessionConfirmAction, weekSwitchBlockedOpen]) + }, [completedCurriculumNotice, controlWarningNotice, courseChangeBlockedOpen, instructorStudioModalOpen, materialModalOpen, moduleShutdownConfirm, platformModalMode, platformModalOpen, recentSessionModalOpen, runningPlatformCloseTarget, sessionConfirmAction, weekSwitchBlockedOpen]) const removePlatformSession = (platformToRemove: PlatformOption, options: { clearWorkspace: boolean }): void => { if (options.clearWorkspace) { const studentSlug = makeStudentSlug(authSession?.user.email) @@ -4165,7 +4268,7 @@ export default function App(): React.JSX.Element { } await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' })) } - const handleDisconnectSession = async (): Promise => { + const handleDisconnectSession = async (options?: { stopModules?: boolean }): Promise => { const platformToDisconnect = connectedPlatform if (!platformToDisconnect) { closeLocalPcWorkspace() @@ -4180,7 +4283,9 @@ export default function App(): React.JSX.Element { }) setPlatformLoading(true) const loadingStartedAt = Date.now() - await stopConnectedWorkspaceModules() + if (options?.stopModules !== false) { + await stopConnectedWorkspaceModules() + } await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' })) const remainingMs = Math.max(0, 2000 - (Date.now() - loadingStartedAt)) window.setTimeout(() => { @@ -4189,7 +4294,7 @@ export default function App(): React.JSX.Element { showTitlebarNotice(`${platformToDisconnect.title} 개발 스튜디오가 종료되었습니다`, 'info', true) }, remainingMs) } - const handleReleaseControl = async (options?: { forceClose?: boolean; expired?: boolean }): Promise => { + const handleReleaseControl = async (options?: { forceClose?: boolean; expired?: boolean; stopModules?: boolean }): Promise => { if (!connectedPlatform) { setSessionConfirmAction(null) return @@ -4207,7 +4312,9 @@ export default function App(): React.JSX.Element { setPlatformLoading(true) let released = false try { - await stopConnectedWorkspaceModules() + if (options?.stopModules !== false) { + await stopConnectedWorkspaceModules() + } await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' })) if (authSession?.accessToken) { await releasePlatformControl(fetchWithFreshAuth, platformToRelease.id) @@ -4234,6 +4341,28 @@ export default function App(): React.JSX.Element { } } + const handleManualSessionEnd = (action: 'disconnect' | 'release', stopModules: boolean): void => { + if (action === 'release') { + void handleReleaseControl({ stopModules }) + return + } + void handleDisconnectSession({ stopModules }) + } + + const confirmManualSessionEnd = (): void => { + const action = sessionConfirmAction + if (!action) return + + const moduleNames = getRunningWorkspaceModuleNamesRef.current?.() ?? [] + setSessionConfirmAction(null) + if (moduleNames.length > 0) { + setModuleShutdownConfirm({ action, moduleNames }) + return + } + + handleManualSessionEnd(action, false) + } + const handleExtendControl = async (): Promise => { if (!connectedPlatform || !canExtendConnectedPlatform || controlExtensionSubmitting) return @@ -4714,6 +4843,9 @@ export default function App(): React.JSX.Element { onRegisterStopRunningModules={(handler) => { stopRunningWorkspaceModulesRef.current = handler }} + onRegisterRunningModuleNames={(handler) => { + getRunningWorkspaceModuleNamesRef.current = handler + }} canReleaseControl={canReleaseConnectedPlatform} canExtendControl={canExtendConnectedPlatform} controlEndsAt={connectedPlatformControlEndsAt} @@ -4960,12 +5092,24 @@ export default function App(): React.JSX.Element { action={sessionConfirmAction} platformName={activePlatformName} onCancel={() => setSessionConfirmAction(null)} - onConfirm={() => { - if (sessionConfirmAction === 'release') { - void handleReleaseControl() - } else { - void handleDisconnectSession() - } + onConfirm={confirmManualSessionEnd} + /> + )} + {moduleShutdownConfirm && ( + { + const { action } = moduleShutdownConfirm + setModuleShutdownConfirm(null) + handleManualSessionEnd(action, false) + }} + onStopModules={() => { + const { action } = moduleShutdownConfirm + setModuleShutdownConfirm(null) + handleManualSessionEnd(action, true) }} /> )} diff --git a/src/renderer/src/components/AppStatusModals.tsx b/src/renderer/src/components/AppStatusModals.tsx index de79037..43d7e47 100644 --- a/src/renderer/src/components/AppStatusModals.tsx +++ b/src/renderer/src/components/AppStatusModals.tsx @@ -204,6 +204,40 @@ export function SessionConfirmModal({ ) } +export function ModuleShutdownConfirmModal({ + overlayClassName, + dark, + action, + moduleNames, + onKeepRunning, + onStopModules, +}: AppModalTheme & { + action: 'disconnect' | 'release' + moduleNames: string[] + onKeepRunning: () => void + onStopModules: () => void +}): React.JSX.Element { + const release = action === 'release' + return ( + } + title="실행 중인 모듈" + message={`${moduleNames.length}개의 모듈이 실행 중입니다. 모듈을 종료할까요?`} + detail={moduleNames.join(', ')} + cancelLabel="아니오" + confirmLabel="예" + onCancel={onKeepRunning} + onConfirm={onStopModules} + > +

+ {release ? '아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.' : '아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.'} +

+
+ ) +} + export function ControlExtensionConfirmModal({ overlayClassName, dark, @@ -248,17 +282,21 @@ function ConfirmStatusModal({ title, message, detail, + cancelLabel = '취소', confirmLabel, onCancel, - onConfirm + onConfirm, + children }: AppModalTheme & { icon: React.ReactNode title: string message: string detail: string + cancelLabel?: string confirmLabel: string onCancel: () => void onConfirm: () => void + children?: React.ReactNode }): React.JSX.Element { return (
@@ -272,8 +310,9 @@ function ConfirmStatusModal({

{detail}

+ {children}
- +
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 1343e93..23a0f3f 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -103,6 +103,7 @@ interface Window { runtime: string } }> + createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }> delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> @@ -255,6 +256,7 @@ interface Window { runtime: string } }> + createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }> start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }> diff --git a/src/renderer/src/features/curriculum/LearningInfoCard.tsx b/src/renderer/src/features/curriculum/LearningInfoCard.tsx index a1e1b5c..0daa332 100644 --- a/src/renderer/src/features/curriculum/LearningInfoCard.tsx +++ b/src/renderer/src/features/curriculum/LearningInfoCard.tsx @@ -1,4 +1,4 @@ -import { BookOpen, Clock3, FileText, Play, Upload } from 'lucide-react' +import { BookOpen, Car, Clock3, FileText, Play, Upload } from 'lucide-react' import type React from 'react' type LearningInfoWeek = { @@ -59,8 +59,9 @@ export default function LearningInfoCard({ -
+
{[ + { label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car }, { label: '예상 학습 시간', value: week.duration, Icon: Clock3 }, ...(week.detail.requiresSourceSubmission ? [{ @@ -82,7 +83,7 @@ export default function LearningInfoCard({ {label} - + {value}
diff --git a/src/renderer/src/features/curriculum/types.ts b/src/renderer/src/features/curriculum/types.ts index 881efb7..5d4b13f 100644 --- a/src/renderer/src/features/curriculum/types.ts +++ b/src/renderer/src/features/curriculum/types.ts @@ -67,6 +67,8 @@ export type CurriculumWeek = { prebuiltArtifacts?: ModulePrebuiltArtifact[] | null defaultParameters?: string[] | null dockerImage?: string | null + packageName?: string | null + launchFile?: string | null }> environmentProfiles?: EnvironmentProfile[] maps?: Array<{ diff --git a/src/renderer/src/features/workspace/ModuleListRow.tsx b/src/renderer/src/features/workspace/ModuleListRow.tsx index 6876297..8b32c56 100644 --- a/src/renderer/src/features/workspace/ModuleListRow.tsx +++ b/src/renderer/src/features/workspace/ModuleListRow.tsx @@ -6,6 +6,7 @@ export type ModuleListRowItem = { state: 'running' | 'stopped' | 'uninstalled' category: string name: string + sourceType?: string | null parameters: string[] cpu: number | null memory: number | null @@ -135,7 +136,7 @@ export default function ModuleListRow({ ] : [] const installAction = module.state === 'uninstalled' - ? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone } + ? { label: module.sourceType === 'CUSTOM' ? '소스 생성' : '설치', Icon: Download, tone: 'install' as ModuleActionTone } : { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone } const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped') @@ -180,8 +181,13 @@ export default function ModuleListRow({ {module.category} - + {module.name} + {module.sourceType === 'CUSTOM' && ( + + 커스텀 + + )} +
+ )} @@ -197,6 +218,112 @@ export default function ModuleListView({ }} /> )} + {moduleAddDialogOpen && ( + setModuleAddDialogOpen(false)} + onCreate={async (input) => { + await onCreateCustomModule(input) + setModuleAddDialogOpen(false) + }} + /> + )} ) } + +export type CustomModuleInput = { + name: string + category: string + dockerImage: string + packageName: string + launchFile: string +} + +function ModuleAddDialog({ + dark, + onClose, + onCreate +}: { + dark: boolean + onClose: () => void + onCreate: (input: CustomModuleInput) => Promise +}): React.JSX.Element { + const [name, setName] = useState('') + const [category, setCategory] = useState('') + const [dockerImage, setDockerImage] = useState('') + const [packageName, setPackageName] = useState('') + const [launchFile, setLaunchFile] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const inputClassName = [ + 'h-9 w-full rounded-md border px-2.5 text-[12px] font-medium outline-none transition', + dark ? 'border-white/12 bg-[#1f1f1f] text-white/82 placeholder:text-white/28 focus:border-[#2f8dff]/55' : 'border-[#cfd6e4] bg-white text-[#111520] placeholder:text-[#9aa3b2] focus:border-[#2f8dff]/55' + ].join(' ') + + return ( +
+
+
+
+

모듈 정보 입력

+

추가할 ROS2 모듈의 기본 정보를 입력합니다.

+
+ +
+
{ + event.preventDefault() + if (submitting) return + setSubmitting(true) + setError(null) + void onCreate({ name: name.trim(), category, dockerImage: dockerImage.trim(), packageName: packageName.trim(), launchFile: launchFile.trim() }) + .catch((reason) => setError(reason instanceof Error ? reason.message : '모듈을 추가하지 못했습니다.')) + .finally(() => setSubmitting(false)) + }}> + + + +
+ + +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ) +} diff --git a/src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx b/src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx index 3133dcc..ccc3626 100644 --- a/src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx @@ -138,7 +138,7 @@ export default function WorkspaceGuidePanel -
+
{resourceMode === 'material' ? (
{materialItems.length ? materialItems.map((item, index) => { @@ -178,7 +178,7 @@ export default function WorkspaceGuidePanel{index + 1}
- {item.title} + {item.title} {active && ( diff --git a/src/renderer/src/features/workspace/types.ts b/src/renderer/src/features/workspace/types.ts index 9031e2e..924b6d9 100644 --- a/src/renderer/src/features/workspace/types.ts +++ b/src/renderer/src/features/workspace/types.ts @@ -5,6 +5,7 @@ export type ModuleParameterPair = { } export type ModuleRow = { + id?: string state: 'running' | 'stopped' | 'uninstalled' sortOrder: number category: string @@ -15,6 +16,9 @@ export type ModuleRow = { templateRepositoryUrl?: string | null sourceType?: string | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null + dockerImage?: string | null + packageName?: string | null + launchFile?: string | null parameters: string[] cpu: number | null memory: number | null diff --git a/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts b/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts index ae0315a..cc1efc7 100644 --- a/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts +++ b/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts @@ -113,7 +113,30 @@ export function useWorkspaceModuleControl({ return parameters.join(' ') }, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile]) + const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise => { + if (!module.folderName || !module.packageName || !module.launchFile) { + throw new Error('커스텀 모듈의 패키지명 또는 Launch 파일 정보가 없습니다.') + } + const input = { + name: module.name, + category: module.category, + folderName: module.folderName, + dockerImage: module.dockerImage ?? null, + packageName: module.packageName, + launchFile: module.launchFile, + } + if (remoteWorkspace) { + await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), input) + return + } + await window.localWorkspace.modules.createCustom(clonePathPreview, input) + }, [clonePathPreview, getRemoteWorkspaceTarget, remoteWorkspace]) + const installModuleOnce = useCallback(async (module: ModuleRow, requestAccessToken: string): Promise => { + if (module.sourceType === 'CUSTOM') { + await createCustomModuleSource(module) + return + } const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, { method: 'POST', headers: { @@ -160,7 +183,7 @@ export function useWorkspaceModuleControl({ prepared.repositoryUrlWithToken, installOptions, ) - }, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber]) + }, [activeRuntimeImages, clonePathPreview, courseKey, createCustomModuleSource, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber]) const remoteModuleStart = useCallback(async (module: ModuleRow): Promise => { await prepareRemoteRuntimeModule(module) diff --git a/src/renderer/src/features/workspace/workspaceModules.ts b/src/renderer/src/features/workspace/workspaceModules.ts index b02eb6f..2889820 100644 --- a/src/renderer/src/features/workspace/workspaceModules.ts +++ b/src/renderer/src/features/workspace/workspaceModules.ts @@ -8,6 +8,7 @@ export const compareModuleShutdownPriority = (a: ModuleRow, b: ModuleRow): numbe export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['modules']): ModuleRow[] { return (curriculumModules ?? []) .map((module) => ({ + id: module.id, state: 'uninstalled' as const, sortOrder: module.sortOrder, category: module.category, @@ -18,6 +19,9 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod templateRepositoryUrl: module.templateRepositoryUrl ?? null, sourceType: module.sourceType ?? null, prebuiltArtifacts: module.prebuiltArtifacts ?? null, + dockerImage: module.dockerImage ?? null, + packageName: module.packageName ?? null, + launchFile: module.launchFile ?? null, parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [], cpu: null, memory: null,