import { app, BrowserWindow, globalShortcut, ipcMain, screen } from 'electron' import type { WebContents } from 'electron' import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' import { existsSync } from 'fs' import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises' import { homedir } from 'os' import { basename, dirname, join, relative, resolve } from 'path' import { is } from '@electron-toolkit/utils' let mainWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null const terminalProcesses = new Map() const terminalStopTimers = new Map() const intentionalTerminalStops = new Set() const moduleCacheRoot = resolve(homedir(), '.caches') const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime') const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda' const autowareRosBridgeImage = 'autoware-ros-bridge' const nav2RosBridgeImage = 'nav2-ros-bridge' const slamRosBridgeImage = 'slam-ros-bridge' const slamSimulationRuntimeImage = 'nav2-slam:humble' const slamSimulationRuntimeContainerName = 'nav2-slam' const rosBridgePort = 8765 const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code') const skippedCacheDirs = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']) const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i const explicitModuleFolders: Record = { 'FD-SIM': '[furive-gui]AW-Rviz_v1.0.0_ws' } const workspaceGitIgnoreLines = [ '', '# Autonomy Studio generated outputs', 'build/', 'install/', 'log/', '__pycache__/', '**/__pycache__/', '*.py[cod]', '.pytest_cache/', '.mypy_cache/', '.cache/' ] const workspaceGitPathspec = [ '--', '.', ':(exclude)build/**', ':(exclude)install/**', ':(exclude)log/**', ':(exclude)**/__pycache__/**', ':(exclude)**/*.pyc', ':(exclude)**/*.pyo', ':(exclude)**/*.pyd', ':(exclude).pytest_cache/**', ':(exclude).mypy_cache/**', ':(exclude).cache/**', ':(exclude).autonomy-studio-git.json' ] const studioGitMetadataFile = '.autonomy-studio-git.json' function getAppIconPath(): string | undefined { const candidates = [ join(process.cwd(), 'resources/icon.png'), join(process.resourcesPath, 'icon.png'), join(process.resourcesPath, 'resources/icon.png') ] return candidates.find((candidate) => existsSync(candidate)) } type CachedModuleFiles = { root: string | null absoluteRoot: string | null files: Record folders: string[] } type CachedModuleInfo = { state: 'running' | 'stopped' | 'uninstalled' sortOrder: number category: string name: string folderName: string sourcePath: string workspacePath: string | null templateRepositoryUrl?: string | null parameters: string[] cpu: number | null memory: number | null runtime: string } type CourseModuleInput = { sortOrder?: number | null category: string name: string folderName: string templateRepositoryUrl?: string | null } type RemoteWorkspaceTarget = { host: string user?: string | null port?: number | null workspaceRoot?: string | null identityFile?: string | null } type VisualizationStack = 'autoware' | 'nav2' | 'slam' type VisualizationBridgeStatus = { ok: boolean running: boolean stack: VisualizationStack | null message?: string } type LearningVideoInput = { url?: string | null title?: string | null } type EnvironmentProfileConfig = { type?: string target?: 'local' | 'remote-jetson' | string agent?: { url?: string token?: string } runtime?: { containers?: Array<{ name?: string image?: string network?: string privileged?: boolean env?: Record volumes?: string[] command?: string }> steps?: Array<{ container?: string mode?: 'execDetached' delayMs?: number command?: string logFile?: string }> } bridge?: { name?: string image?: string buildContext?: string port?: number env?: Record } } type GitChangedFile = { path: string status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' staged: boolean unstaged: boolean } type GitCommitInfo = { hash: string shortHash: string author: string date: string message: string } type GitStatusInfo = { ok: boolean branch: string | null ahead: number behind: number files: GitChangedFile[] selectedFilePath: string | null diff: string commits: GitCommitInfo[] message?: string } type StudioGitSubmission = { id: string submittedAt: string moduleName: string commit: GitCommitInfo } type StudioGitMetadata = { baseCommit?: string submissions?: StudioGitSubmission[] } function toRendererModulePath(moduleName: string, absolutePath: string, rootPath: string): string { return `modules/${moduleName}/${relative(rootPath, absolutePath).split('\\').join('/')}` } function toRendererRootPath(rendererRoot: string, absolutePath: string, rootPath: string): string { const relativePath = relative(rootPath, absolutePath).split('\\').join('/') return relativePath ? `${rendererRoot}/${relativePath}` : rendererRoot } function expandUserPath(inputPath?: string | null): string | null { if (!inputPath?.trim()) return null const trimmedPath = inputPath.trim() if (trimmedPath === '~') return homedir() if (trimmedPath.startsWith('~/')) return resolve(homedir(), trimmedPath.slice(2)) return resolve(trimmedPath) } function parseModuleFolderName(folderName: string): { name: string; category: string } { const categoryMatch = folderName.match(/^\[([^\]]+)\]/) const rawCategory = categoryMatch?.[1]?.replace(/^furive-/, '') ?? 'Module' const withoutPrefix = folderName.replace(/^\[[^\]]+\]/, '') const name = withoutPrefix.replace(/_v[\d.]+_ws$/i, '').replace(/_ws$/i, '') const category = rawCategory .split(/[-_\s]+/) .filter(Boolean) .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) .join(' ') return { name, category } } function parseModuleDockerInfo(folderName: string): { containerName: string; name: string; version: string; containerKey: string } | null { const match = folderName.match(/^\[([^\]]+)\](.+)_v([^/]+)_ws$/) if (!match) return null const [, containerName, name, version] = match return { containerName, name, version, containerKey: `${containerName}-${name}_v${version}` } } function isAllowedContainerName(containerKey: string): boolean { return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(containerKey) } function isAllowedRuntimeContainerName(containerName: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(containerName) } function isCacheModuleFolder(folderName: string): boolean { return folderName.endsWith('_ws') && !skippedCacheDirs.has(folderName) && !folderName.startsWith('.') } async function findCachedModuleRoot(moduleName: string): Promise { const explicitFolder = explicitModuleFolders[moduleName] if (explicitFolder) { const explicitPath = resolve(moduleCacheRoot, explicitFolder) try { if ((await stat(explicitPath)).isDirectory()) return explicitPath } catch { return null } } const normalizedModuleName = moduleName.toLowerCase() const entries = await readdir(moduleCacheRoot, { withFileTypes: true }) const matched = entries.find((entry) => { if (!entry.isDirectory() || !isCacheModuleFolder(entry.name)) return false const parsed = parseModuleFolderName(entry.name) return parsed.name.toLowerCase() === normalizedModuleName || entry.name.toLowerCase().includes(normalizedModuleName) }) return matched ? resolve(moduleCacheRoot, matched.name) : null } async function findWorkspaceModuleRoot(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) return null if (folderName?.trim()) { const directPath = resolve(resolvedWorkspaceRoot, folderName.trim()) try { if ((await stat(directPath)).isDirectory()) return directPath } catch { return null } } try { const entries = await readdir(resolvedWorkspaceRoot, { withFileTypes: true }) const matched = entries.find((entry) => { if (!entry.isDirectory()) return false const parsed = parseModuleFolderName(entry.name) return parsed.name.toLowerCase() === moduleName.toLowerCase() || entry.name.toLowerCase() === moduleName.toLowerCase() }) return matched ? resolve(resolvedWorkspaceRoot, matched.name) : null } catch { return null } } async function findModuleRoot(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise { return findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName) } async function collectAvailableModules( workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null, ): Promise<{ modules: CachedModuleInfo[]; cacheRoot: string; workspaceRoot: string | null }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) const moduleInputs = (courseModules ?? []) .filter((module) => module.name?.trim() && module.folderName?.trim()) .map((module, index) => { const sortOrder = Number(module.sortOrder) return { ...module, sortOrder: Number.isFinite(sortOrder) ? sortOrder : index + 1 } }) .sort((a, b) => a.sortOrder - b.sortOrder) const dockerStatuses = await readDockerStatuses().catch(() => new Map()) const dockerStats = await readDockerStats().catch(() => new Map()) const modules = await Promise.all(moduleInputs .map(async (module): Promise => { const parsed = parseModuleFolderName(module.folderName) const dockerInfo = parseModuleDockerInfo(module.folderName) const name = module.name || parsed.name const category = module.category || parsed.category const sourcePath = resolve(moduleCacheRoot, module.folderName) const workspacePath = resolvedWorkspaceRoot ? resolve(resolvedWorkspaceRoot, module.folderName) : null const installed = workspacePath ? existsSync(workspacePath) : false const dockerStatus = dockerInfo ? dockerStatuses.get(dockerInfo.containerKey) : null const dockerStat = dockerInfo ? dockerStats.get(dockerInfo.containerKey) : null const rosParams = installed && workspacePath ? await readModuleRosParams(workspacePath) : '' return { state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled', sortOrder: module.sortOrder, category, name, folderName: module.folderName, sourcePath, workspacePath, templateRepositoryUrl: module.templateRepositoryUrl ?? null, parameters: splitRosParams(rosParams), cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, runtime: dockerStatus?.running ? formatDockerRuntime(dockerStatus.status) : '-' } })) return { modules: modules.sort((a, b) => a.sortOrder - b.sortOrder), cacheRoot: moduleCacheRoot, workspaceRoot: resolvedWorkspaceRoot } } async function collectRemoteAvailableModules( targetInput?: RemoteWorkspaceTarget | null, courseModules?: CourseModuleInput[] | null, ): Promise<{ modules: CachedModuleInfo[]; cacheRoot: string; workspaceRoot: string | null }> { const target = normalizeRemoteTarget(targetInput) const moduleInputs = (courseModules ?? []) .filter((module) => module.name?.trim() && module.folderName?.trim()) .map((module, index) => { const sortOrder = Number(module.sortOrder) return { ...module, sortOrder: Number.isFinite(sortOrder) ? sortOrder : index + 1 } }) .sort((a, b) => a.sortOrder - b.sortOrder) const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 15000) const dockerStatuses = new Map() const dockerStats = new Map() const installedModules = new Map() for (const line of output.split('\n')) { if (!line.trim()) continue const [kind, first = '', second = '', third = ''] = line.split('\t') if (kind === 'DOCKER' && first) { dockerStatuses.set(first, { exists: true, running: /^Up\b/i.test(second), status: second }) } if (kind === 'STATS' && first) { const cpu = parseDockerPercent(second) const memory = parseDockerPercent(third) if (cpu !== null && memory !== null) dockerStats.set(first, { cpu, memory }) } if (kind === 'MODULE' && first && second) { installedModules.set(first, { path: second, parameters: splitRosParams(decodeRemoteField(third)) }) } } const modules = moduleInputs.map((module): CachedModuleInfo => { const parsed = parseModuleFolderName(module.folderName) const dockerInfo = parseModuleDockerInfo(module.folderName) const installed = installedModules.get(module.folderName) const dockerStatus = dockerInfo ? dockerStatuses.get(dockerInfo.containerKey) : null const dockerStat = dockerInfo ? dockerStats.get(dockerInfo.containerKey) : null return { state: installed ? (dockerStatus?.running ? 'running' : 'stopped') : 'uninstalled', sortOrder: module.sortOrder, category: module.category || parsed.category, name: module.name || parsed.name, folderName: module.folderName, sourcePath: module.templateRepositoryUrl ?? '', workspacePath: installed?.path ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null, parameters: installed?.parameters ?? [], cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, runtime: dockerStatus?.running ? formatDockerRuntime(dockerStatus.status) : '-' } }) return { modules, cacheRoot: remoteSshDestination(targetInput ?? target), workspaceRoot: target.workspaceRoot } } function parseDockerPercent(value: string): number | null { const numeric = Number(value.replace('%', '').trim()) return Number.isFinite(numeric) ? numeric : null } function formatDockerRuntime(status: string): string { const match = status.match(/^Up\s+(.+?)(?:\s+\(|$)/i) const runtime = match?.[1]?.trim() if (!runtime) return '실행 중' const normalized = runtime.toLowerCase() if (normalized === 'less than a second') return '<1 sec' return runtime .replace(/\babout\s+/gi, '') .replace(/\ban?\s+(seconds?|minutes?|hours?|days?|weeks?|months?|years?)\b/gi, '1 $1') .replace(/\bseconds?\b/gi, 'sec') .replace(/\bminutes?\b/gi, 'min') .replace(/\bhours?\b/gi, 'h') .replace(/\bdays?\b/gi, 'd') .replace(/\bweeks?\b/gi, 'w') .replace(/\bmonths?\b/gi, 'mo') .replace(/\byears?\b/gi, 'y') .replace(/\s+/g, ' ') .trim() } function withGitCredential(repositoryUrl: string): string { const token = process.env.GITEA_TOKEN?.trim() if (!token) return repositoryUrl try { const url = new URL(repositoryUrl) if (url.protocol !== 'http:' && url.protocol !== 'https:') return repositoryUrl url.username = 'oauth2' url.password = token return url.toString() } catch { return repositoryUrl } } function remoteRepositoryUrl(repositoryUrl: string): string { try { const url = new URL(repositoryUrl) const host = url.hostname.toLowerCase() if (host === 'localhost' || host === '127.0.0.1' || host === '::1') { url.hostname = process.env.AUTONOMY_STUDIO_REMOTE_GITEA_HOST?.trim() || '192.168.0.242' return url.toString() } } catch (error) { return repositoryUrl } return repositoryUrl } async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise { return new Promise((resolvePromise, rejectPromise) => { let output = '' const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env, GIT_TERMINAL_PROMPT: '0' } }) const timeout = setTimeout(() => { if (!child.killed) child.kill('SIGTERM') rejectPromise(new Error(`${command} ${args.join(' ')} timed out`)) }, timeoutMs) child.stdout.on('data', (data: Buffer) => { output += data.toString() }) child.stderr.on('data', (data: Buffer) => { output += data.toString() }) child.on('error', (error) => { clearTimeout(timeout) rejectPromise(error) }) child.on('close', (code) => { clearTimeout(timeout) if (code === 0) resolvePromise(output) else rejectPromise(new Error(output.trim() || `${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}`)) }) }) } async function runCommand(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs?: number): Promise { await runCommandOutput(command, args, cwd, env, timeoutMs) } function defaultSshIdentityFile(): string { return join(homedir(), '.autonomy-studio', 'ssh', 'id_ed25519') } function remoteUserHome(user: string): string { return user === 'root' ? '/root' : `/home/${user}` } function normalizeRemoteWorkspaceRoot(value: string | null | undefined, user: string): string { const raw = value?.trim() || '~/autonomy-studio/workspaces' if (raw === '~') return remoteUserHome(user) if (raw.startsWith('~/')) return `${remoteUserHome(user)}/${raw.slice(2)}` return raw } function normalizeRemoteTarget(target?: RemoteWorkspaceTarget | null): Required> & { workspaceRoot: string; identityFile: string } { const host = target?.host?.trim() if (!host) throw new Error('원격 플랫폼 SSH 호스트를 확인하지 못했습니다.') const user = target?.user?.trim() || process.env.AUTONOMY_STUDIO_SSH_USER?.trim() if (!user) throw new Error('원격 플랫폼 SSH 사용자를 확인하지 못했습니다.') const port = Number(target?.port ?? process.env.AUTONOMY_STUDIO_SSH_PORT ?? 22) return { host, user, port: Number.isFinite(port) && port > 0 ? port : 22, workspaceRoot: normalizeRemoteWorkspaceRoot(target?.workspaceRoot, user), identityFile: target?.identityFile?.trim() || process.env.AUTONOMY_STUDIO_SSH_IDENTITY_FILE?.trim() || defaultSshIdentityFile() } } function remoteSshDestination(target: RemoteWorkspaceTarget): string { const normalized = normalizeRemoteTarget(target) return `${normalized.user}@${normalized.host}` } async function runSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs = 120_000): Promise { const normalized = normalizeRemoteTarget(target) return runCommandOutput( 'ssh', [ '-p', String(normalized.port), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=5', '-i', normalized.identityFile, `${normalized.user}@${normalized.host}`, command ], undefined, undefined, timeoutMs ) } async function runSsh(target: RemoteWorkspaceTarget, command: string, timeoutMs?: number): Promise { await runSshOutput(target, command, timeoutMs) } function remotePathJoin(root: string, child: string): string { return `${root.replace(/\/+$/, '')}/${child.replace(/^\/+/, '')}` } function shellArgs(args: string[]): string { return args.map(quoteShellSingle).join(' ') } function expandRemotePathVar(varName: string): string[] { return [ `case "$${varName}" in`, ` "~") ${varName}="$HOME" ;;`, ` "~/"*) ${varName}="$HOME/\${${varName}#~/}" ;;`, 'esac' ] } function remoteContainerKey(folderName?: string | null): string | null { if (!folderName) return null const dockerInfo = parseModuleDockerInfo(folderName) return dockerInfo?.containerKey ?? null } function decodeRemoteField(value?: string): string { if (!value) return '' try { return Buffer.from(value, 'base64').toString('utf8') } catch { return value } } function encodeBase64(value: string): string { return Buffer.from(value, 'utf8').toString('base64') } function normalizeGitRelativePath(filePath?: string | null): string { const normalized = filePath?.trim().replace(/\\/g, '/') ?? '' if (!normalized || normalized.startsWith('/') || normalized.split('/').some((part) => part === '..')) { throw new Error('Invalid git path') } return normalized } function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModuleInput[]): string { const moduleFolders = modules .filter((module) => module.folderName?.trim()) .map((module) => module.folderName.trim()) return [ 'set +e', `ROOT=${quoteShellSingle(workspaceRoot)}`, ...expandRemotePathVar('ROOT'), 'timeout 12s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true', 'timeout 12s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true', ...moduleFolders.map((folderName) => { const modulePath = remotePathJoin('$ROOT', folderName) return [ `FOLDER=${quoteShellSingle(folderName)}`, `MODULE_PATH="${modulePath}"`, 'if [ -d "$MODULE_PATH" ]; then', ' PARAMS=""', ' if [ -f "$MODULE_PATH/run.sh" ]; then', ' PARAMS=$(grep -E "^ROS_PARAM=" "$MODULE_PATH/run.sh" | head -n 1)', ' PARAMS=${PARAMS#ROS_PARAM=}', " PARAMS=${PARAMS#\\'}", " PARAMS=${PARAMS%\\'}", ' PARAMS=${PARAMS#\\"}', ' PARAMS=${PARAMS%\\"}', ' fi', ' printf "MODULE\\t%s\\t%s\\t%s\\n" "$FOLDER" "$MODULE_PATH" "$(printf "%s" "$PARAMS" | base64 -w0)"', 'else', ' printf "MODULE\\t%s\\t\\t\\n" "$FOLDER"', 'fi' ].join('\n') }) ].join('\n') } function mapGitStatusCode(code: string): GitChangedFile['status'] { if (code === 'A') return 'added' if (code === 'D') return 'deleted' if (code === 'R') return 'renamed' if (code === '?') return 'untracked' return 'modified' } function isGeneratedWorkspacePath(filePath: string): boolean { return ( filePath === '.gitignore' || filePath === studioGitMetadataFile || filePath === 'build' || filePath === 'install' || filePath === 'log' || filePath.startsWith('build/') || filePath.startsWith('install/') || filePath.startsWith('log/') || filePath.includes('/__pycache__/') || filePath.startsWith('__pycache__/') || /\.(pyc|pyo|pyd)$/i.test(filePath) || filePath.startsWith('.pytest_cache/') || filePath.startsWith('.mypy_cache/') || filePath.startsWith('.cache/') ) } function parseGitStatus(stdout: string): GitChangedFile[] { return stdout .split('\n') .map((line) => { if (!line.trim()) return null if (line.startsWith('## ')) return null const x = line[0] ?? ' ' const y = line[1] ?? ' ' const rawPath = line.slice(3).trim() const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ').at(-1)?.trim() ?? rawPath : rawPath if (isGeneratedWorkspacePath(filePath)) return null const statusCode = x === '?' && y === '?' ? '?' : x.trim() || y.trim() || 'M' return { path: filePath, status: mapGitStatusCode(statusCode), staged: x !== ' ' && x !== '?', unstaged: y !== ' ' || (x === '?' && y === '?') } }) .filter((file): file is GitChangedFile => Boolean(file)) } function parseGitBranch(stdout: string): { branch: string | null; ahead: number; behind: number } { const firstLine = stdout.split('\n').find((line) => line.startsWith('## ')) ?? '' const branchMatch = firstLine.match(/^##\s+([^\s.]+|HEAD)(?:\.\.\.[^\s]+)?(?:\s+\[(.+)\])?/) const statusText = branchMatch?.[2] ?? '' const ahead = Number(statusText.match(/ahead\s+(\d+)/)?.[1] ?? 0) const behind = Number(statusText.match(/behind\s+(\d+)/)?.[1] ?? 0) return { branch: branchMatch?.[1] && branchMatch[1] !== 'HEAD' ? branchMatch[1] : null, ahead, behind } } function parseGitCommits(stdout: string): GitCommitInfo[] { return stdout .split('\n') .map((line) => { if (!line.trim()) return null const [hash = '', shortHash = '', author = '', date = '', ...messageParts] = line.split('\x1f') return { hash, shortHash, author, date, message: messageParts.join('\x1f') } }) .filter((commit): commit is GitCommitInfo => Boolean(commit?.hash)) } async function ensureWorkspaceGitIgnore(moduleRoot: string): Promise { const gitIgnorePath = resolve(moduleRoot, '.gitignore') const currentContent = await readFile(gitIgnorePath, 'utf8').catch(() => '') const currentLines = new Set(currentContent.split(/\r?\n/)) const missingLines = workspaceGitIgnoreLines.filter((line) => line && !currentLines.has(line)) if (!missingLines.length) return const prefix = currentContent.trimEnd() const nextContent = `${prefix}${prefix ? '\n\n' : ''}${workspaceGitIgnoreLines.join('\n')}\n` await writeFile(gitIgnorePath, nextContent, 'utf8') } async function readStudioGitMetadata(moduleRoot: string): Promise { const metadataPath = resolve(moduleRoot, studioGitMetadataFile) try { return JSON.parse(await readFile(metadataPath, 'utf8')) as StudioGitMetadata } catch { return {} } } async function writeStudioGitMetadata(moduleRoot: string, metadata: StudioGitMetadata): Promise { await writeFile(resolve(moduleRoot, studioGitMetadataFile), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8') } async function readStudioGitBaseCommit(moduleRoot: string): Promise { const metadata = await readStudioGitMetadata(moduleRoot) return metadata.baseCommit?.trim() || null } async function writeStudioGitBaseCommit(moduleRoot: string, baseCommit: string): Promise { const metadata = await readStudioGitMetadata(moduleRoot) await writeStudioGitMetadata(moduleRoot, { ...metadata, baseCommit }) } async function appendStudioGitSubmission(moduleRoot: string, submission: StudioGitSubmission): Promise { const metadata = await readStudioGitMetadata(moduleRoot) await writeStudioGitMetadata(moduleRoot, { ...metadata, submissions: [submission, ...(metadata.submissions ?? [])].slice(0, 100) }) } async function inferStudioGitBaseCommit(moduleRoot: string): Promise { const explicitBaseCommit = await readStudioGitBaseCommit(moduleRoot) if (explicitBaseCommit) return explicitBaseCommit const firstCommit = await runCommandOutput('git', ['rev-list', '--max-parents=0', 'HEAD'], moduleRoot, undefined, 8000) .then((output) => output.split('\n')[0]?.trim() || null) .catch(() => null) if (firstCommit) await writeStudioGitBaseCommit(moduleRoot, firstCommit).catch(() => undefined) return firstCommit } async function resolveGitModuleRoot(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise { const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const isRepository = await runCommandOutput('git', ['rev-parse', '--is-inside-work-tree'], moduleRoot, undefined, 8000) .then((output) => output.trim() === 'true') .catch(() => false) if (!isRepository) throw new Error('Git 저장소가 아닙니다.') await ensureWorkspaceGitIgnore(moduleRoot) return moduleRoot } async function resolveRemoteGitModuleRoot( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, ): Promise<{ target: ReturnType; moduleRoot: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName const output = await runSshOutput(target, [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'TARGET="$ROOT/$FOLDER"', 'test -d "$TARGET"', 'test "$(git -C "$TARGET" rev-parse --is-inside-work-tree 2>/dev/null)" = "true"', 'printf "%s" "$TARGET"' ].join('\n'), 8000) const moduleRoot = output.trim() if (!moduleRoot) throw new Error('원격 Git 저장소를 찾을 수 없습니다.') return { target, moduleRoot } } async function readGitFileDiff(moduleRoot: string, filePath?: string | null): Promise { const normalizedPath = filePath?.trim() if (!normalizedPath) return '' const trackedDiff = await runCommandOutput('git', ['diff', '--no-ext-diff', '--', normalizedPath], moduleRoot, undefined, 8000).catch(() => '') if (trackedDiff.trim()) return trackedDiff const stagedDiff = await runCommandOutput('git', ['diff', '--cached', '--no-ext-diff', '--', normalizedPath], moduleRoot, undefined, 8000).catch(() => '') if (stagedDiff.trim()) return stagedDiff const absolutePath = resolve(moduleRoot, normalizedPath) if (!absolutePath.startsWith(moduleRoot) || !existsSync(absolutePath)) return '' const content = await readFile(absolutePath, 'utf8').catch(() => '') if (!content) return '' const lines = content.split('\n') return [ `diff --git a/${normalizedPath} b/${normalizedPath}`, 'new file mode 100644', 'index 0000000..0000000', '--- /dev/null', `+++ b/${normalizedPath}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`) ].join('\n') } async function readRemoteGitFileDiff(target: RemoteWorkspaceTarget, moduleRoot: string, filePath?: string | null): Promise { const normalizedPath = filePath?.trim() if (!normalizedPath) return '' const script = [ 'set +e', `TARGET=${quoteShellSingle(moduleRoot)}`, `SELECTED=${quoteShellSingle(normalizedPath)}`, 'DIFF="$(git -C "$TARGET" diff --no-ext-diff -- "$SELECTED" 2>/dev/null)"', 'if [ -z "$DIFF" ]; then', ' DIFF="$(git -C "$TARGET" diff --cached --no-ext-diff -- "$SELECTED" 2>/dev/null)"', 'fi', 'if [ -z "$DIFF" ] && [ -f "$TARGET/$SELECTED" ]; then', ' DIFF="$(git -C "$TARGET" diff --no-ext-diff --no-index /dev/null "$TARGET/$SELECTED" 2>/dev/null)"', 'fi', 'printf "%s" "$DIFF" | base64 -w0' ].join('\n') const output = await runSshOutput(target, script, 10000).catch(() => '') return decodeRemoteField(output.trim()) } async function readWorkspaceGitStatus( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null ): Promise { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-b', '-uall', ...workspaceGitPathspec], moduleRoot, undefined, 8000) const { branch, ahead, behind } = parseGitBranch(statusOutput) const files = parseGitStatus(statusOutput) const nextSelectedPath = selectedFilePath && files.some((file) => file.path === selectedFilePath) ? selectedFilePath : files[0]?.path ?? null const baseCommit = await inferStudioGitBaseCommit(moduleRoot) const logRange = baseCommit ? `${baseCommit}..HEAD` : 'HEAD' const [diff, commitOutput] = await Promise.all([ readGitFileDiff(moduleRoot, nextSelectedPath), runCommandOutput('git', ['log', '--max-count=20', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict', logRange], moduleRoot, undefined, 8000).catch(() => '') ]) return { ok: true, branch, ahead, behind, files, selectedFilePath: nextSelectedPath, diff, commits: parseGitCommits(commitOutput) } } catch (error) { return { ok: false, branch: null, ahead: 0, behind: 0, files: [], selectedFilePath: null, diff: '', commits: [], message: error instanceof Error ? error.message : String(error) } } } async function readRemoteWorkspaceGitStatus( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null ): Promise { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const statusArgs = shellArgs(['status', '--porcelain=v1', '-b', '-uall', ...workspaceGitPathspec]) const logArgs = shellArgs(['log', '--max-count=20', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict']) const script = [ 'set +e', `TARGET=${quoteShellSingle(moduleRoot)}`, `META="$TARGET/${studioGitMetadataFile}"`, 'BASE="$(grep -m1 \'"baseCommit"\' "$META" 2>/dev/null | sed -E \'s/.*"baseCommit"[[:space:]]*:[[:space:]]*"([^"]+)".*/\\1/\' || true)"', `printf "STATUS\\t"`, `git -C "$TARGET" ${statusArgs} 2>/dev/null | base64 -w0`, 'printf "\\nLOG\\t"', `if [ -n "$BASE" ]; then git -C "$TARGET" ${logArgs} "$BASE..HEAD" 2>/dev/null | base64 -w0; else git -C "$TARGET" ${logArgs} HEAD 2>/dev/null | base64 -w0; fi`, 'printf "\\n"' ].join('\n') const output = await runSshOutput(target, script, 12000) const sections = new Map() output.split('\n').forEach((line) => { const [key, value = ''] = line.split('\t') if (key) sections.set(key, decodeRemoteField(value)) }) const statusOutput = sections.get('STATUS') ?? '' const { branch, ahead, behind } = parseGitBranch(statusOutput) const files = parseGitStatus(statusOutput) const nextSelectedPath = selectedFilePath && files.some((file) => file.path === selectedFilePath) ? selectedFilePath : files[0]?.path ?? null const diff = await readRemoteGitFileDiff(target, moduleRoot, nextSelectedPath) return { ok: true, branch, ahead, behind, files, selectedFilePath: nextSelectedPath, diff, commits: parseGitCommits(sections.get('LOG') ?? '') } } catch (error) { return { ok: false, branch: null, ahead: 0, behind: 0, files: [], selectedFilePath: null, diff: '', commits: [], message: error instanceof Error ? error.message : String(error) } } } async function submitWorkspaceGitChanges( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null ): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const statusOutput = await runCommandOutput('git', ['status', '--porcelain=v1', '-uall', ...workspaceGitPathspec], moduleRoot, undefined, 8000) const changedFiles = parseGitStatus(statusOutput) if (!changedFiles.length) return { ok: false, message: '제출할 변경사항이 없습니다.' } const commitMessage = message?.trim() || `${moduleName} 제출` const name = authorName?.trim() || process.env.STUDIO_GIT_AUTHOR_NAME?.trim() || 'Autonomy Studio LMS Bot' const email = authorEmail?.trim() || process.env.STUDIO_GIT_AUTHOR_EMAIL?.trim() || 'lms-bot@futuredrive.net' await runCommand('git', ['add', '-A', '--', ...changedFiles.map((file) => file.path)], moduleRoot, undefined, 15000) await runCommand('git', ['-c', `user.name=${name}`, '-c', `user.email=${email}`, 'commit', '-m', commitMessage], moduleRoot, undefined, 30000) await runCommand('git', ['push', '-u', 'origin', 'HEAD'], moduleRoot, undefined, 120000) const commitOutput = await runCommandOutput('git', ['log', '-1', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict'], moduleRoot, undefined, 8000) const commit = parseGitCommits(commitOutput)[0] if (commit && submissionId?.trim()) { await appendStudioGitSubmission(moduleRoot, { id: submissionId.trim(), submittedAt: new Date().toISOString(), moduleName, commit }).catch(() => undefined) } return { ok: true, message: '제출되었습니다.', commit } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function restoreWorkspaceGitFile( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null ): Promise<{ ok: boolean; message: string }> { try { const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const normalizedPath = normalizeGitRelativePath(filePath) if (status === 'untracked') { const absolutePath = resolve(moduleRoot, normalizedPath) const relativeToRoot = relative(moduleRoot, absolutePath) if (relativeToRoot.startsWith('..') || relativeToRoot === '..' || resolve(relativeToRoot) === relativeToRoot) { throw new Error('Invalid git path') } await rm(absolutePath, { recursive: true, force: true }) } else { await runCommand('git', ['restore', '--staged', '--worktree', '--', normalizedPath], moduleRoot, undefined, 15000) } return { ok: true, message: '변경사항을 되돌렸습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function readRemoteStudioGitMetadata(target: RemoteWorkspaceTarget, moduleRoot: string): Promise { const output = await runSshOutput( target, `if [ -f ${quoteShellSingle(`${moduleRoot}/${studioGitMetadataFile}`)} ]; then cat ${quoteShellSingle(`${moduleRoot}/${studioGitMetadataFile}`)} | base64 -w0; fi`, 8000 ).catch(() => '') if (!output.trim()) return {} try { return JSON.parse(decodeRemoteField(output.trim())) as StudioGitMetadata } catch { return {} } } async function writeRemoteStudioGitMetadata(target: RemoteWorkspaceTarget, moduleRoot: string, metadata: StudioGitMetadata): Promise { const encoded = encodeBase64(`${JSON.stringify(metadata, null, 2)}\n`) await runSsh( target, `printf %s ${quoteShellSingle(encoded)} | base64 -d > ${quoteShellSingle(`${moduleRoot}/${studioGitMetadataFile}`)}`, 8000 ) } async function appendRemoteStudioGitSubmission(target: RemoteWorkspaceTarget, moduleRoot: string, submission: StudioGitSubmission): Promise { const metadata = await readRemoteStudioGitMetadata(target, moduleRoot) await writeRemoteStudioGitMetadata(target, moduleRoot, { ...metadata, submissions: [submission, ...(metadata.submissions ?? [])].slice(0, 100) }) } async function submitRemoteWorkspaceGitChanges( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null ): Promise<{ ok: boolean; message: string; commit?: GitCommitInfo }> { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const status = await readRemoteWorkspaceGitStatus(target, moduleName, folderName, null) const changedFiles = status.files if (!changedFiles.length) return { ok: false, message: '제출할 변경사항이 없습니다.' } const commitMessage = message?.trim() || `${moduleName} 제출` const name = authorName?.trim() || process.env.STUDIO_GIT_AUTHOR_NAME?.trim() || 'Autonomy Studio LMS Bot' const email = authorEmail?.trim() || process.env.STUDIO_GIT_AUTHOR_EMAIL?.trim() || 'lms-bot@futuredrive.net' const addFiles = changedFiles.map((file) => file.path) const script = [ 'set -e', `TARGET=${quoteShellSingle(moduleRoot)}`, `git -C "$TARGET" add -A -- ${shellArgs(addFiles)}`, `git -C "$TARGET" -c ${quoteShellSingle(`user.name=${name}`)} -c ${quoteShellSingle(`user.email=${email}`)} commit -m ${quoteShellSingle(commitMessage)}`, 'git -C "$TARGET" push -u origin HEAD', `git -C "$TARGET" log -1 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict | base64 -w0` ].join('\n') const commitOutput = decodeRemoteField((await runSshOutput(target, script, 150000)).trim()) const commit = parseGitCommits(commitOutput)[0] if (commit && submissionId?.trim()) { await appendRemoteStudioGitSubmission(target, moduleRoot, { id: submissionId.trim(), submittedAt: new Date().toISOString(), moduleName, commit }).catch(() => undefined) } return { ok: true, message: '제출되었습니다.', commit } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function restoreRemoteWorkspaceGitFile( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null ): Promise<{ ok: boolean; message: string }> { try { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const normalizedPath = normalizeGitRelativePath(filePath) const absolutePath = remotePathJoin(moduleRoot, normalizedPath) const command = status === 'untracked' ? `rm -rf ${quoteShellSingle(absolutePath)}` : `git -C ${quoteShellSingle(moduleRoot)} restore --staged --worktree -- ${quoteShellSingle(normalizedPath)}` await runSsh(target, command, 15000) return { ok: true, message: '변경사항을 되돌렸습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function readWorkspaceGitSubmissions( modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null ): Promise<{ ok: boolean; submissions: StudioGitSubmission[]; message?: string }> { try { const submissions: StudioGitSubmission[] = [] for (const module of modules) { const moduleRoot = await resolveGitModuleRoot(module.moduleName, workspaceRoot, module.folderName).catch(() => null) if (!moduleRoot) continue const metadata = await readStudioGitMetadata(moduleRoot) submissions.push(...(metadata.submissions ?? [])) } return { ok: true, submissions: submissions.sort((a, b) => Date.parse(b.submittedAt) - Date.parse(a.submittedAt)) } } catch (error) { return { ok: false, submissions: [], message: error instanceof Error ? error.message : String(error) } } } async function readRemoteWorkspaceGitSubmissions( targetInput: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>, ): Promise<{ ok: boolean; submissions: StudioGitSubmission[]; message?: string }> { try { const target = normalizeRemoteTarget(targetInput) const submissions: StudioGitSubmission[] = [] for (const module of modules) { const resolved = await resolveRemoteGitModuleRoot(target, module.moduleName, module.folderName).catch(() => null) if (!resolved) continue const metadata = await readRemoteStudioGitMetadata(target, resolved.moduleRoot) submissions.push(...(metadata.submissions ?? [])) } return { ok: true, submissions: submissions.sort((a, b) => Date.parse(b.submittedAt) - Date.parse(a.submittedAt)) } } catch (error) { return { ok: false, submissions: [], message: error instanceof Error ? error.message : String(error) } } } async function readWorkspaceGitCommitDiff( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, commitHash?: string | null ): Promise<{ ok: boolean; diff: string; message?: string }> { try { const hash = commitHash?.trim() if (!hash) throw new Error('커밋 해시가 없습니다.') const moduleRoot = await resolveGitModuleRoot(moduleName, workspaceRoot, folderName) const diff = await runCommandOutput('git', ['show', '--format=', '--no-ext-diff', `${hash}^!`, ...workspaceGitPathspec], moduleRoot, undefined, 15000) return { ok: true, diff } } catch (error) { return { ok: false, diff: '', message: error instanceof Error ? error.message : String(error) } } } async function readRemoteWorkspaceGitCommitDiff( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null ): Promise<{ ok: boolean; diff: string; message?: string }> { try { const hash = commitHash?.trim() if (!hash) throw new Error('커밋 해시가 없습니다.') const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const showArgs = shellArgs(['show', '--format=', '--no-ext-diff', `${hash}^!`, ...workspaceGitPathspec]) const output = await runSshOutput(target, `git -C ${quoteShellSingle(moduleRoot)} ${showArgs} | base64 -w0`, 20000) return { ok: true, diff: decodeRemoteField(output.trim()) } } catch (error) { return { ok: false, diff: '', message: error instanceof Error ? error.message : String(error) } } } async function readDockerStatuses(): Promise> { const statuses = new Map() const stdout = await runCommandOutput('docker', ['ps', '-a', '--format', '{{.Names}}\t{{.Status}}'], undefined, undefined, 5000) for (const line of stdout.split('\n')) { const trimmed = line.trim() if (!trimmed) continue const [name, status = ''] = trimmed.split('\t') statuses.set(name, { exists: true, running: /^Up\b/i.test(status), status }) } return statuses } async function readDockerStats(): Promise> { const stats = new Map() const stdout = await runCommandOutput('docker', ['stats', '--no-stream', '--format', '{{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}'], undefined, undefined, 8000) for (const line of stdout.split('\n')) { const trimmed = line.trim() if (!trimmed) continue const [name, cpuValue = '', memoryValue = ''] = trimmed.split('\t') const cpu = parseDockerPercent(cpuValue) const memory = parseDockerPercent(memoryValue) if (!name || cpu === null || memory === null) continue stats.set(name, { cpu, memory }) } return stats } function visualizationBridgeContainerName(stack: VisualizationStack): string { if (stack === 'autoware') return 'autoware-ros-bridge' if (stack === 'nav2') return 'nav2-ros-bridge' return 'slam-ros-bridge' } function visualizationBridgeImageName(stack: VisualizationStack): string { if (stack === 'autoware') return autowareRosBridgeImage if (stack === 'nav2') return nav2RosBridgeImage return slamRosBridgeImage } function normalizeVisualizationStack(value?: string | null): VisualizationStack { if (value === 'slam') return 'slam' return value === 'nav2' ? 'nav2' : 'autoware' } async function findPlanningWorkspacePath(workspaceRoot?: string | null): Promise { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) return null try { const entries = await readdir(resolvedWorkspaceRoot, { withFileTypes: true }) const planning = entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .find((name) => /^\[furive-planning\].+_ws$/i.test(name) || /AW-Planning/i.test(name)) return planning ? resolve(resolvedWorkspaceRoot, planning) : null } catch { return null } } function resolveResourceContextPath(contextPath = 'resources/slam-ros-bridge'): string | null { const normalizedContextPath = contextPath.replace(/^\/+/, '') const candidates = [ join(app.getAppPath(), normalizedContextPath), join(process.resourcesPath, normalizedContextPath.replace(/^resources\//, '')), join(process.cwd(), normalizedContextPath) ] return candidates.find((candidate) => existsSync(join(candidate, 'Dockerfile'))) ?? null } async function ensureBridgeImage(image: string, buildContext?: string | null): Promise { if (!buildContext) return const contextPath = resolveResourceContextPath(buildContext) if (!contextPath) throw new Error(`브릿지 Docker context를 찾을 수 없습니다: ${buildContext}`) await runCommand('docker', ['build', '-t', image, contextPath], undefined, undefined, 120000) } function expandDockerVolume(volume: string): string { const separatorIndex = volume.indexOf(':') if (separatorIndex <= 0) return volume const hostPath = volume.slice(0, separatorIndex) const containerPath = volume.slice(separatorIndex + 1) return `${expandUserPath(hostPath) ?? hostPath}:${containerPath}` } function runtimeLaunchCommand(command: string, logFile: string, rosDomainId: string, env?: Record): string { const exports = Object.entries(env ?? {}).map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) return [ 'source /opt/ros/humble/setup.bash', `export ROS_DOMAIN_ID=${rosDomainId}`, ...exports, `(${command}) > ${logFile} 2>&1` ].join('; ') } async function execRuntimeDetached(containerName: string, command: string, logFile: string, rosDomainId: string, env?: Record): Promise { await runCommand( 'docker', [ 'exec', '-d', containerName, 'bash', '-lc', runtimeLaunchCommand(command, logFile, rosDomainId, env) ], undefined, undefined, 15000 ) } async function ensureEnvironmentRuntime(profile: EnvironmentProfileConfig | null | undefined, rosDomainIdInput?: string | null): Promise { const rosDomainId = String(rosDomainIdInput ?? process.env.ROS_DOMAIN_ID ?? '77').trim() || '77' const display = process.env.DISPLAY?.trim() || ':1' const containers = profile?.runtime?.containers ?? [] const steps = profile?.runtime?.steps ?? [] if (!containers.length) return const statuses = await readDockerStatuses().catch(() => new Map()) await runCommand('xhost', ['+local:docker'], undefined, undefined, 5000).catch(() => undefined) for (const container of containers) { const name = container.name?.trim() const image = container.image?.trim() if (!name || !image || !isAllowedRuntimeContainerName(name)) throw new Error('환경 프로파일 컨테이너 설정이 올바르지 않습니다.') if (statuses.get(name)?.running) { await runCommand('docker', ['stop', name], undefined, undefined, 15000).catch(() => undefined) } else if (statuses.get(name)?.exists) { await runCommand('docker', ['rm', '-f', name], undefined, undefined, 10000).catch(() => undefined) } const args = ['run', '--rm', '-d', '--name', name] if (container.network) args.push('--network', container.network) if (container.privileged) args.push('--privileged') const env = { DISPLAY: display, ...container.env, ROS_DOMAIN_ID: rosDomainId } Object.entries(env).forEach(([key, value]) => args.push('-e', `${key}=${value}`)) ;(container.volumes ?? []).forEach((volume) => args.push('-v', expandDockerVolume(volume))) args.push(image, 'bash', '-lc', container.command?.trim() || 'tail -f /dev/null') await runCommand('docker', args, undefined, undefined, 30000) } for (const step of steps) { const containerName = step.container?.trim() const command = step.command?.trim() if (!containerName || !command || !isAllowedRuntimeContainerName(containerName)) continue if (step.delayMs && step.delayMs > 0) await new Promise((resolveTimer) => setTimeout(resolveTimer, step.delayMs)) await execRuntimeDetached(containerName, command, step.logFile?.trim() || '/tmp/autonomy-studio-step.log', rosDomainId, containers.find((container) => container.name === containerName)?.env) } } async function readVisualizationBridgeStatus(): Promise { try { const statuses = await readDockerStatuses().catch(() => new Map()) const autowareRunning = Boolean(statuses.get(visualizationBridgeContainerName('autoware'))?.running) const nav2Running = Boolean(statuses.get(visualizationBridgeContainerName('nav2'))?.running) const slamRunning = Boolean(statuses.get(visualizationBridgeContainerName('slam'))?.running) if (!autowareRunning && !nav2Running && !slamRunning) return { ok: true, running: false, stack: null } if (slamRunning) { const response = await fetch(`http://localhost:${rosBridgePort}/health`).catch(() => null) return { ok: Boolean(response?.ok), running: true, stack: 'slam', message: response?.ok ? undefined : 'SLAM 브릿지 상태를 확인할 수 없습니다.' } } const response = await fetch(`http://localhost:${rosBridgePort}/stack`).catch(() => null) const payload = response?.ok ? await response.json().catch(() => null) : null const stack = normalizeVisualizationStack(typeof payload?.stack === 'string' ? payload.stack : nav2Running ? 'nav2' : 'autoware') return { ok: true, running: true, stack } } catch (error) { return { ok: false, running: false, stack: null, message: error instanceof Error ? error.message : String(error) } } } async function waitForVisualizationBridgeReady(stack: VisualizationStack, bridgePort: number, timeoutMs = 20000): Promise { const endpoint = stack === 'slam' ? 'health' : 'stack' const startedAt = Date.now() let lastMessage = '' while (Date.now() - startedAt < timeoutMs) { const response = await fetch(`http://localhost:${bridgePort}/${endpoint}`).catch((error) => { lastMessage = error instanceof Error ? error.message : String(error) return null }) if (response?.ok) return if (response) lastMessage = `HTTP ${response.status}` await new Promise((resolve) => setTimeout(resolve, 350)) } throw new Error(`시각화 시작 대기 시간이 초과되었습니다.${lastMessage ? ` (${lastMessage})` : ''}`) } function normalizeRuntimeTarget(target?: string | null): 'local' | 'remote-jetson' { return target === 'remote-jetson' ? 'remote-jetson' : 'local' } function envValue(key: string): string | null { const value = process.env[key]?.trim() return value ? value : null } function stackEnvPrefix(stack: VisualizationStack): string { return stack.toUpperCase() } function withRuntimeProfileDefaults( profile: EnvironmentProfileConfig | null | undefined, stack: VisualizationStack, ): EnvironmentProfileConfig | null | undefined { if (!profile) return profile const target = normalizeRuntimeTarget(profile.target) const prefix = target === 'remote-jetson' ? `JETSON_${stackEnvPrefix(stack)}` : `LOCAL_${stackEnvPrefix(stack)}` const bridgeImage = envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image const runtimeImage = envValue(`${prefix}_RUNTIME_IMAGE`) const agentUrl = target === 'remote-jetson' ? envValue(`${prefix}_AGENT_URL`) ?? envValue('JETSON_AGENT_URL') ?? profile.agent?.url : profile.agent?.url const agentToken = target === 'remote-jetson' ? envValue(`${prefix}_AGENT_TOKEN`) ?? envValue('JETSON_AGENT_TOKEN') ?? profile.agent?.token : profile.agent?.token return { ...profile, target, agent: agentUrl || agentToken ? { ...profile.agent, url: agentUrl ?? profile.agent?.url, token: agentToken ?? profile.agent?.token } : profile.agent, bridge: bridgeImage ? { ...profile.bridge, image: bridgeImage } : profile.bridge, runtime: runtimeImage ? { ...profile.runtime, containers: (profile.runtime?.containers ?? []).map((container, index) => ( index === 0 ? { ...container, image: runtimeImage } : container )) } : profile.runtime } } async function callRemoteVisualizationAgent( profile: EnvironmentProfileConfig, action: 'start' | 'stop' | 'status', payload: Record = {}, ): Promise { const agentUrl = profile.agent?.url?.trim().replace(/\/+$/, '') if (!agentUrl) throw new Error('원격 제어 Agent URL이 설정되지 않았습니다.') const headers: Record = { 'Content-Type': 'application/json' } const token = profile.agent?.token?.trim() if (token) headers.Authorization = `Bearer ${token}` const response = await fetch(`${agentUrl}/visualization/${action}`, { method: action === 'status' ? 'GET' : 'POST', headers, body: action === 'status' ? undefined : JSON.stringify({ profile, ...payload }) }) if (!response.ok) { const detail = await response.text().catch(() => '') throw new Error(`원격 Agent 요청 실패: HTTP ${response.status}${detail ? ` ${detail}` : ''}`) } const result = await response.json().catch(() => ({})) return { ok: result?.ok !== false, running: Boolean(result?.running ?? action === 'start'), stack: normalizeVisualizationStack(typeof result?.stack === 'string' ? result.stack : profile.type), message: typeof result?.message === 'string' ? result.message : undefined } } async function startVisualizationBridge( stackInput?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, ): Promise { const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput) const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack) const target = normalizeRuntimeTarget(resolvedProfile?.target) const bridge = resolvedProfile?.bridge const containerName = bridge?.name?.trim() || visualizationBridgeContainerName(stack) const bridgeImage = bridge?.image?.trim() || visualizationBridgeImageName(stack) const bridgePort = bridge?.port ?? rosBridgePort const otherContainerNames = (['autoware', 'nav2', 'slam'] as VisualizationStack[]) .filter((item) => item !== stack) .map(visualizationBridgeContainerName) try { if (target === 'remote-jetson' && resolvedProfile) { return await callRemoteVisualizationAgent(resolvedProfile, 'start', { stack, workspaceRoot, rosDomainId: String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0' }) } const statuses = await readDockerStatuses().catch(() => new Map()) if (statuses.get(containerName)?.running) { const response = await fetch(`http://localhost:${bridgePort}/${stack === 'slam' ? 'health' : 'stack'}`).catch(() => null) if (response?.ok) { return readVisualizationBridgeStatus() } await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) } const runningOtherContainers = otherContainerNames.filter((name) => statuses.get(name)?.running) if (runningOtherContainers.length) { await runCommand('docker', ['stop', ...runningOtherContainers], undefined, undefined, 15000).catch(() => undefined) } if (statuses.get(containerName)?.exists) { await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) } if (resolvedProfile?.runtime?.containers?.length) { await ensureEnvironmentRuntime(resolvedProfile, rosDomainId) } if (bridge?.buildContext) { await ensureBridgeImage(bridgeImage, bridge.buildContext) } else if (stack === 'autoware') { await ensureBridgeImage(bridgeImage, 'resources/autoware-ros-bridge') } else if (stack === 'nav2') { await ensureBridgeImage(bridgeImage, 'resources/nav2-ros-bridge') } else if (stack === 'slam') { await ensureBridgeImage(bridgeImage, 'resources/slam-ros-bridge') } const args = [ 'run', '-d', '--name', containerName, '--net=host', '-v', '/dev/shm:/dev/shm', '-e', `ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`, '-e', 'ROS_LOCALHOST_ONLY=0', '-e', `ROBOT_STACK=${stack}`, '-e', 'RCUTILS_COLORIZED_OUTPUT=1', '-e', 'RCUTILS_LOGGING_BUFFERED_STREAM=0', '-e', 'PYTHONUNBUFFERED=1', ] const cycloneConfigPath = join(studioRuntimeRoot, 'docker', 'cyclonedds_config.xml') if (existsSync(cycloneConfigPath)) { args.push('-v', `${cycloneConfigPath}:/cyclonedds_config.xml:ro`, '-e', 'CYCLONEDDS_URI=file:///cyclonedds_config.xml') } if (stack === 'autoware') { args.push('-e', 'MAP_POINT_STRIDE=8', '-e', 'MAP_POINT_LIMIT=80000') args.push('-e', 'AUTOWARE_ROUTE_INPUT=rviz_topic') const planningWorkspacePath = await findPlanningWorkspacePath(workspaceRoot) if (planningWorkspacePath) args.push('-v', `${planningWorkspacePath}:/autoware_ws:ro`) } const bridgeEnv = bridge?.env ?? (stack === 'slam' ? { ODOM_TOPIC: '/odom', SCAN_TOPIC: '/scan', MAP_TOPIC: '/map', CMD_VEL_TOPIC: '/cmd_vel', NAVIGATE_TO_POSE_ACTION: '/navigate_to_pose' } : {}) Object.entries(bridgeEnv).forEach(([key, value]) => args.push('-e', `${key}=${value}`)) if (stack === 'slam') { args.push('-e', `PORT=${bridgePort}`) } args.push(bridgeImage) await runCommand('docker', args, undefined, undefined, 15000) await waitForVisualizationBridgeReady(stack, bridgePort) return readVisualizationBridgeStatus() } catch (error) { return { ok: false, running: false, stack: null, message: error instanceof Error ? error.message : String(error) } } } function getEnvironmentProfileContainerNames(environmentProfile?: EnvironmentProfileConfig | null): string[] { const names = new Set() const profileType = environmentProfile?.type?.toLowerCase() const bridgeName = environmentProfile?.bridge?.name?.trim() if (bridgeName && isAllowedRuntimeContainerName(bridgeName)) names.add(bridgeName) ;(environmentProfile?.runtime?.containers ?? []).forEach((container) => { const name = container.name?.trim() if (name && isAllowedRuntimeContainerName(name)) names.add(name) }) if (profileType === 'slam') names.add(slamSimulationRuntimeContainerName) return Array.from(names) } async function stopVisualizationBridge(environmentProfile?: EnvironmentProfileConfig | null): Promise { try { const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase()) const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack) if (normalizeRuntimeTarget(resolvedProfile?.target) === 'remote-jetson' && resolvedProfile) { return await callRemoteVisualizationAgent(resolvedProfile, 'stop', { stack }) } const statuses = await readDockerStatuses().catch(() => new Map()) const targetNames = new Set((['autoware', 'nav2', 'slam'] as VisualizationStack[]) .map(visualizationBridgeContainerName) ) getEnvironmentProfileContainerNames(resolvedProfile).forEach((name) => targetNames.add(name)) if (statuses.get(visualizationBridgeContainerName('slam'))?.running && statuses.get(slamSimulationRuntimeContainerName)?.running) { targetNames.add(slamSimulationRuntimeContainerName) } const running = Array.from(targetNames).filter((name) => statuses.get(name)?.running) if (running.length) await runCommand('docker', ['stop', ...running], undefined, undefined, 30000) const stale = Array.from(targetNames).filter((name) => { const status = statuses.get(name) return status?.exists && !status.running }) if (stale.length) { await runCommand('docker', ['rm', '-f', ...stale], undefined, undefined, 15000).catch(() => undefined) } return { ok: true, running: false, stack: null } } catch (error) { return { ok: false, running: false, stack: null, message: error instanceof Error ? error.message : String(error) } } } async function stopRunningWorkspaceContainers(): Promise<{ ok: boolean; message: string }> { try { const stdout = await runCommandOutput('docker', ['ps', '--format', '{{.Names}}'], undefined, undefined, 5000) const containerNames = stdout .split('\n') .map((line) => line.trim()) .filter((name) => name && isAllowedContainerName(name)) const uniqueContainerNames = Array.from(new Set(containerNames)) if (!uniqueContainerNames.length) return { ok: true, message: '중지할 컨테이너가 없습니다.' } await runCommand('docker', ['stop', ...uniqueContainerNames], undefined, undefined, 30000) return { ok: true, message: '중지했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function installModuleFromCache( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, ): Promise<{ ok: boolean; module: CachedModuleInfo }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const targetFolderName = folderName?.trim() || moduleName const targetRoot = resolve(resolvedWorkspaceRoot, targetFolderName) await mkdir(resolvedWorkspaceRoot, { recursive: true }) if (!existsSync(targetRoot)) { const normalizedTemplateRepositoryUrl = templateRepositoryUrl?.trim() const normalizedStudentRepositoryUrl = studentRepositoryUrl?.trim() if (normalizedTemplateRepositoryUrl) { const studentRepositoryWithCredential = normalizedStudentRepositoryUrl ? withGitCredential(normalizedStudentRepositoryUrl) : null const studentRepositoryBranches = studentRepositoryWithCredential ? await runCommandOutput('git', ['ls-remote', '--heads', studentRepositoryWithCredential], undefined, undefined, 15000).catch(() => '') : '' if (studentRepositoryWithCredential && studentRepositoryBranches.trim()) { await runCommand('git', ['clone', studentRepositoryWithCredential, targetRoot]) } else { await runCommand('git', ['clone', withGitCredential(normalizedTemplateRepositoryUrl), targetRoot]) if (studentRepositoryWithCredential) { await runCommand('git', ['remote', 'set-url', 'origin', studentRepositoryWithCredential], targetRoot) await runCommand('git', ['push', '-u', 'origin', 'HEAD:main'], targetRoot) const baseCommit = await runCommandOutput('git', ['rev-parse', 'HEAD'], targetRoot, undefined, 8000).then((output) => output.trim()) await writeStudioGitBaseCommit(targetRoot, baseCommit) } } } else { const sourceRoot = folderName ? resolve(moduleCacheRoot, folderName) : await findCachedModuleRoot(moduleName) if (!sourceRoot) throw new Error('캐시 모듈을 찾을 수 없습니다.') if (!(await stat(sourceRoot)).isDirectory()) throw new Error('캐시 모듈을 찾을 수 없습니다.') await cp(sourceRoot, targetRoot, { recursive: true, force: false, errorOnExist: true }) } } const { name, category } = parseModuleFolderName(targetFolderName) return { ok: true, module: { state: 'stopped', sortOrder: 0, category, name, folderName: targetFolderName, sourcePath: templateRepositoryUrl?.trim() ? templateRepositoryUrl.trim() : resolve(moduleCacheRoot, targetFolderName), workspacePath: targetRoot, templateRepositoryUrl: templateRepositoryUrl ?? null, parameters: [], cpu: null, memory: null, runtime: '-' } } } async function installRemoteWorkspaceModule( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, ): Promise<{ ok: boolean; module: CachedModuleInfo }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName const normalizedTemplateRepositoryUrl = templateRepositoryUrl?.trim() const normalizedStudentRepositoryUrl = studentRepositoryUrl?.trim() if (!normalizedTemplateRepositoryUrl) throw new Error('원격 모듈 설치에는 템플릿 저장소가 필요합니다.') const remoteTemplateRepositoryUrl = remoteRepositoryUrl(normalizedTemplateRepositoryUrl) const remoteStudentRepositoryUrl = normalizedStudentRepositoryUrl ? remoteRepositoryUrl(normalizedStudentRepositoryUrl) : '' const templateRepositoryWithCredential = withGitCredential(remoteTemplateRepositoryUrl) const studentRepositoryWithCredential = normalizedStudentRepositoryUrl ? withGitCredential(remoteStudentRepositoryUrl) : '' const targetRoot = remotePathJoin(target.workspaceRoot, targetFolderName) const script = [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, `TARGET="$ROOT/$FOLDER"`, `TEMPLATE_REPO="$(printf %s ${quoteShellSingle(encodeBase64(templateRepositoryWithCredential))} | base64 -d)"`, `STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`, 'mkdir -p "$ROOT"', 'case "$ROOT" in', ' /tmp/autonomy-studio/sessions/*) rm -rf "$TARGET" ;;', 'esac', 'if [ ! -d "$TARGET" ]; then', ' if [ -n "$STUDENT_REPO" ] && [ -n "$(git ls-remote --heads "$STUDENT_REPO" 2>/dev/null)" ]; then', ' git clone "$STUDENT_REPO" "$TARGET"', ' else', ' git clone "$TEMPLATE_REPO" "$TARGET"', ' if [ -n "$STUDENT_REPO" ]; then', ' git -C "$TARGET" remote set-url origin "$STUDENT_REPO"', ' git -C "$TARGET" push -u origin HEAD:main', ' BASE_COMMIT="$(git -C "$TARGET" rev-parse HEAD)"', ` printf '{"baseCommit":"%s"}\\n' "$BASE_COMMIT" > "$TARGET/${studioGitMetadataFile}"`, ' fi', ' fi', 'fi', 'rm -rf "$TARGET/build" "$TARGET/install" "$TARGET/log"', 'printf "OK\\t%s\\n" "$TARGET"' ].join('\n') await runSsh(target, script, 180000) const { name, category } = parseModuleFolderName(targetFolderName) return { ok: true, module: { state: 'stopped', sortOrder: 0, category, name, folderName: targetFolderName, sourcePath: normalizedTemplateRepositoryUrl, workspacePath: targetRoot, templateRepositoryUrl: normalizedTemplateRepositoryUrl, parameters: [], cpu: null, memory: null, runtime: '-' } } } async function deleteWorkspaceModule(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<{ ok: boolean }> { const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) return { ok: true } await rm(moduleRoot, { recursive: true, force: true }) return { ok: true } } function resolveAutowareDockerRunScript(): string { return resolve(studioRuntimeRoot, 'docker', 'docker_run_autoware-universe:humble-20240301-cuda.sh') } function buildAutowareDockerRunScript(): string { return [ '#!/usr/bin/env bash', 'set -e', '', 'DOCKER_CONTAINER_NAME=$1', 'DOCKER_USER=root', 'SHARD_FOLDER_PATH=${SHARD_DIR}/$2', 'DOCKER_SHARD_FOLDER_PATH=/autoware', 'DOCKER_IMAGE_NAME=$5', 'ROS_PARAMS=$6', 'ROS_DOMAIN_ID=$7', 'GPU_ENABLED=${8:-false}', 'BUILD_BEFORE_RUN=${9:-false}', 'GPU_ARGS=()', 'GL_ARGS=(-e LIBGL_ALWAYS_SOFTWARE=1)', 'CONTAINER_COMMAND=\'cd /autoware && bash /autoware/run.sh "$@"\'', 'if [[ "$BUILD_BEFORE_RUN" == "true" || "$BUILD_BEFORE_RUN" == "1" ]]; then', ' CONTAINER_COMMAND=\'cd /autoware && rm -rf build install log && source /opt/ros/humble/setup.bash && colcon build --symlink-install && bash /autoware/run.sh "$@"\'', 'fi', 'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then', ' GPU_ARGS=(--gpus all)', ' GL_ARGS=(-e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=graphics,compute,utility,display)', 'fi', '', 'docker run \\', ' -d -P --name "$DOCKER_CONTAINER_NAME" \\', ' "${GPU_ARGS[@]}" \\', ' --volume="$SHARD_FOLDER_PATH:$DOCKER_SHARD_FOLDER_PATH:rw" \\', ' -v ./config:$DOCKER_SHARD_FOLDER_PATH/config/ \\', ' -u "$DOCKER_USER" \\', ' -e DISPLAY -e TERM -e QT_X11_NO_MITSHM=1 \\', ' "${GL_ARGS[@]}" \\', ' -v /tmp/.X11-unix:/tmp/.X11-unix \\', ' -v /etc/localtime:/etc/localtime:ro \\', ' -e RCUTILS_COLORIZED_OUTPUT=1 \\', ' -e ROS_LOCALHOST_ONLY=1 \\', ' -v ./docker/cyclonedds_config.xml:/cyclonedds_config.xml \\', ' -e CYCLONEDDS_URI=file:///cyclonedds_config.xml \\', ' -v /dev/bus/usb:/dev/bus/usb \\', ' --device /dev/snd \\', ' --net=host \\', ' -e ROS_DOMAIN_ID="$ROS_DOMAIN_ID" \\', ' --entrypoint /bin/bash \\', ' "$DOCKER_IMAGE_NAME" -lc "$CONTAINER_COMMAND" _ "$ROS_PARAMS"', '' ].join('\n') } function buildCycloneDdsConfig(): string { return [ '', '', ' ', ' ', ' ', ' ', ' ', ' default', ' 65500B', ' ', ' ', ' auto', ' 120', ' ', ' ', ' ', ' 500kB', ' ', ' ', ' ', '', '' ].join('\n') } async function ensureStudioRuntimeAssets(): Promise { const dockerRoot = resolve(studioRuntimeRoot, 'docker') const configRoot = resolve(studioRuntimeRoot, 'config') const scriptPath = resolveAutowareDockerRunScript() const legacyScriptPath = resolve(studioRuntimeRoot, 'docker', 'docker_run_autoware-universe_humble-20240301-cuda.sh') const cycloneConfigPath = resolve(dockerRoot, 'cyclonedds_config.xml') await mkdir(dockerRoot, { recursive: true }) await mkdir(configRoot, { recursive: true }) await writeFile(scriptPath, buildAutowareDockerRunScript(), 'utf8') await writeFile(legacyScriptPath, buildAutowareDockerRunScript(), 'utf8') await writeFile(cycloneConfigPath, buildCycloneDdsConfig(), 'utf8') } async function ensureRemoteStudioRuntimeAssets(targetInput?: RemoteWorkspaceTarget | null): Promise { const target = normalizeRemoteTarget(targetInput) const runtimeRoot = '~/autonomy-studio/runtime' const scriptBase64 = encodeBase64(buildAutowareDockerRunScript()) const cycloneBase64 = encodeBase64(buildCycloneDdsConfig()) await runSsh(target, [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, ...expandRemotePathVar('RUNTIME_ROOT'), 'DOCKER_ROOT="$RUNTIME_ROOT/docker"', 'CONFIG_ROOT="$RUNTIME_ROOT/config"', 'SCRIPT_PATH="$DOCKER_ROOT/docker_run_autoware-universe:humble-20240301-cuda.sh"', 'LEGACY_SCRIPT_PATH="$DOCKER_ROOT/docker_run_autoware-universe_humble-20240301-cuda.sh"', 'CYCLONE_CONFIG_PATH="$DOCKER_ROOT/cyclonedds_config.xml"', 'mkdir -p "$DOCKER_ROOT" "$CONFIG_ROOT"', `printf %s ${quoteShellSingle(scriptBase64)} | base64 -d > "$SCRIPT_PATH"`, 'cp "$SCRIPT_PATH" "$LEGACY_SCRIPT_PATH"', 'chmod +x "$SCRIPT_PATH" "$LEGACY_SCRIPT_PATH"', `printf %s ${quoteShellSingle(cycloneBase64)} | base64 -d > "$CYCLONE_CONFIG_PATH"` ].join('\n'), 15000) return runtimeRoot } async function readModuleRosParams(moduleRoot: string): Promise { try { const runScript = await readFile(resolve(moduleRoot, 'run.sh'), 'utf8') const quotedMatch = runScript.match(/^ROS_PARAM=(['"])(.*?)\1/m) if (quotedMatch) return quotedMatch[2].trim() const unquotedMatch = runScript.match(/^ROS_PARAM=([^\r\n#]*)/m) return unquotedMatch?.[1]?.trim() ?? '' } catch { return '' } } function splitRosParams(rosParams: string): string[] { return rosParams.trim() ? rosParams.trim().split(/\s+/) : [] } function quoteShellSingle(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } async function updateWorkspaceModuleParameters( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams = '', ): Promise<{ ok: boolean; parameters: string[] }> { const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const runScriptPath = resolve(moduleRoot, 'run.sh') const currentScript = await readFile(runScriptPath, 'utf8') const nextParamLine = `ROS_PARAM=${quoteShellSingle(rosParams.trim())}` const nextScript = /^ROS_PARAM=.*$/m.test(currentScript) ? currentScript.replace(/^ROS_PARAM=.*$/m, nextParamLine) : currentScript.replace(/^RUN_CMD=.*$/m, (line) => `${line}\n${nextParamLine}`) await writeFile(runScriptPath, nextScript, 'utf8') return { ok: true, parameters: splitRosParams(rosParams) } } async function startWorkspaceModule( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, ): Promise<{ ok: boolean; message: string }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const targetFolderName = folderName?.trim() if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const moduleRoot = await findWorkspaceModuleRoot(moduleName, resolvedWorkspaceRoot, targetFolderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') await ensureStudioRuntimeAssets() const scriptPath = resolveAutowareDockerRunScript() if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`) const statuses = await readDockerStatuses().catch(() => new Map()) const current = statuses.get(dockerInfo.containerKey) if (current?.running) return { ok: true, message: '이미 실행 중입니다.' } if (current?.exists) { await runCommand('docker', ['rm', '-f', dockerInfo.containerKey], undefined, undefined, 10000).catch(() => undefined) } const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) await runCommand( 'bash', [ scriptPath, dockerInfo.containerKey, targetFolderName, 'localhost', 'localhost', autowareDockerImage, rosParams, String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0', gpuEnabled ? 'true' : 'false' ], studioRuntimeRoot, { SHARD_DIR: resolvedWorkspaceRoot }, 30000 ) return { ok: true, message: '실행 요청을 보냈습니다.' } } async function startRemoteWorkspaceModule( targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, ): Promise<{ ok: boolean; message: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const existsOutput = await runSshOutput(target, [ `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'MODULE_PATH="$ROOT/$FOLDER"', '[ -d "$MODULE_PATH" ] && printf yes || printf no' ].join('\n'), 8000) if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.') const runtimeRoot = await ensureRemoteStudioRuntimeAssets(target) const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } if (currentStatus.trim()) { await runSsh(target, `docker rm -f ${quoteShellSingle(dockerInfo.containerKey)} >/dev/null 2>&1 || true`, 10000) } const remoteRosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await runSshOutput(target, [ `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'MODULE_PATH="$ROOT/$FOLDER"', 'RUN_SH="$MODULE_PATH/run.sh"', 'PARAMS=$([ -f "$RUN_SH" ] && grep -E "^ROS_PARAM=" "$RUN_SH" | head -n 1 || true)', 'PARAMS=${PARAMS#ROS_PARAM=}', "PARAMS=${PARAMS#\\'}", "PARAMS=${PARAMS%\\'}", 'PARAMS=${PARAMS#\\"}', 'PARAMS=${PARAMS%\\"}', 'printf "%s" "$PARAMS"' ].join('\n'), 8000) .then((output) => output.trim()) await runSsh( target, [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), `FOLDER=${quoteShellSingle(targetFolderName)}`, 'MODULE_PATH="$ROOT/$FOLDER"', `BUILD_CONTAINER=${quoteShellSingle(`${dockerInfo.containerKey}-build`)}`, `DOCKER_IMAGE_NAME=${quoteShellSingle(autowareDockerImage)}`, `ROS_DOMAIN_ID_VALUE=${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')}`, 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true', 'docker run --rm \\', ' --name "$BUILD_CONTAINER" \\', ' --volume="$MODULE_PATH:/autoware:rw" \\', ' -u root \\', ' --net=host \\', ' -e ROS_DOMAIN_ID="$ROS_DOMAIN_ID_VALUE" \\', ' -v /etc/localtime:/etc/localtime:ro \\', ' --entrypoint /bin/bash \\', ' "$DOCKER_IMAGE_NAME" -lc "cd /autoware && rm -rf build install log && source /opt/ros/humble/setup.bash && colcon build --symlink-install"' ].join('\n'), 900000 ) await runSsh( target, [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, ...expandRemotePathVar('RUNTIME_ROOT'), `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), 'SCRIPT_PATH="$RUNTIME_ROOT/docker/docker_run_autoware-universe:humble-20240301-cuda.sh"', 'cd "$RUNTIME_ROOT"', `SHARD_DIR="$ROOT" bash "$SCRIPT_PATH" ${quoteShellSingle(dockerInfo.containerKey)} ${quoteShellSingle(targetFolderName)} localhost localhost ${quoteShellSingle(autowareDockerImage)} ${quoteShellSingle(remoteRosParams)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} ${gpuEnabled ? 'true' : 'false'} false` ].join('\n'), 30000 ) return { ok: true, message: '실행 요청을 보냈습니다.' } } async function stopWorkspaceModule( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, ): Promise<{ ok: boolean; message: string }> { const targetFolderName = folderName?.trim() if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, targetFolderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const statuses = await readDockerStatuses().catch(() => new Map()) const current = statuses.get(dockerInfo.containerKey) if (!current?.exists) return { ok: true, message: '중지할 컨테이너가 없습니다.' } await runCommand('docker', ['stop', dockerInfo.containerKey], undefined, undefined, 30000).catch(async (error) => { if (current.running) throw error }) return { ok: true, message: '중지했습니다.' } } async function stopRemoteWorkspaceModule( targetInput: RemoteWorkspaceTarget, folderName?: string | null, ): Promise<{ ok: boolean; message: string }> { const target = normalizeRemoteTarget(targetInput) const containerKey = remoteContainerKey(folderName) if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) if (!currentStatus.trim()) return { ok: true, message: '중지할 컨테이너가 없습니다.' } if (/^Up\b/i.test(currentStatus.trim())) { await runSsh(target, `docker stop ${quoteShellSingle(containerKey)}`, 30000) } return { ok: true, message: '중지했습니다.' } } async function stopWorkspaceModules( modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null, ): Promise<{ ok: boolean; message: string }> { try { const statuses = await readDockerStatuses().catch(() => new Map()) const containerKeys = new Set() for (const module of modules) { const targetFolderName = module.folderName?.trim() if (!targetFolderName) continue const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey) || containerKeys.has(dockerInfo.containerKey)) continue const moduleRoot = await findWorkspaceModuleRoot(module.moduleName, workspaceRoot, targetFolderName) if (!moduleRoot) continue const current = statuses.get(dockerInfo.containerKey) if (current?.running) containerKeys.add(dockerInfo.containerKey) } if (!containerKeys.size) return { ok: true, message: '중지할 컨테이너가 없습니다.' } await runCommand('docker', ['stop', ...Array.from(containerKeys)], undefined, undefined, 30000) return { ok: true, message: '중지했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function stopRemoteWorkspaceModules( targetInput: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>, ): Promise<{ ok: boolean; message: string }> { try { const target = normalizeRemoteTarget(targetInput) const containerKeys = Array.from(new Set(modules .map((module) => remoteContainerKey(module.folderName)) .filter((key): key is string => Boolean(key && isAllowedContainerName(key))))) if (!containerKeys.length) return { ok: true, message: '중지할 컨테이너가 없습니다.' } const statusOutput = await runSshOutput(target, 'timeout 12s docker ps --format "{{.Names}}" 2>/dev/null || true', 20000) const running = new Set(statusOutput.split('\n').map((line) => line.trim()).filter(Boolean)) const targets = containerKeys.filter((containerKey) => running.has(containerKey)) if (!targets.length) return { ok: true, message: '중지할 컨테이너가 없습니다.' } await runSsh(target, `docker stop ${targets.map(quoteShellSingle).join(' ')}`, 30000) return { ok: true, message: '중지했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function resetRemoteWorkspaceSession( targetInput: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>, ): Promise<{ ok: boolean; message: string }> { try { const target = normalizeRemoteTarget(targetInput) await stopRemoteWorkspaceModules(target, modules).catch(() => ({ ok: false, message: '모듈 중지 실패' })) const script = [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), 'case "$ROOT" in', ' /tmp/autonomy-studio/sessions/*) rm -rf "$ROOT" ;;', ' *) printf "skip non-session root: %s\\n" "$ROOT" ;;', 'esac' ].join('\n') await runSsh(target, script, 30000) return { ok: true, message: '초기화했습니다.' } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } } async function restartWorkspaceModule( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, ): Promise<{ ok: boolean; message: string }> { await stopWorkspaceModule(moduleName, workspaceRoot, folderName) return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId) } async function restartRemoteWorkspaceModule( target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, ): Promise<{ ok: boolean; message: string }> { await stopRemoteWorkspaceModule(target, folderName) return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId) } async function readWorkspaceModuleLogs( moduleName: string, workspaceRoot?: string | null, folderName?: string | null, ): Promise<{ ok: boolean; logs: string; message?: string }> { const targetFolderName = folderName?.trim() if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.') const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, targetFolderName) if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.') const statuses = await readDockerStatuses().catch(() => new Map()) if (!statuses.get(dockerInfo.containerKey)?.exists) { return { ok: true, logs: '', message: '생성된 컨테이너가 없습니다.' } } try { const logs = await runCommandOutput('docker', ['logs', '--timestamps', '--tail', '300', dockerInfo.containerKey], undefined, undefined, 10000) return { ok: true, logs } } catch (error) { return { ok: false, logs: '', message: error instanceof Error ? error.message : String(error) } } } async function readRemoteWorkspaceModuleLogs( targetInput: RemoteWorkspaceTarget, folderName?: string | null, ): Promise<{ ok: boolean; logs: string; message?: string }> { const target = normalizeRemoteTarget(targetInput) const containerKey = remoteContainerKey(folderName) if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') const existsOutput = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Names}}" 2>/dev/null || true`, 20000) if (!existsOutput.trim()) return { ok: true, logs: '', message: '생성된 컨테이너가 없습니다.' } try { const logs = await runSshOutput(target, `docker logs --timestamps --tail 300 ${quoteShellSingle(containerKey)}`, 10000) return { ok: true, logs } } catch (error) { return { ok: false, logs: '', message: error instanceof Error ? error.message : String(error) } } } async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise { const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName) if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] } const files: Record = {} const folders: string[] = [] const visit = async (currentPath: string, depth: number): Promise => { if (depth > 10) return const entries = await readdir(currentPath, { withFileTypes: true }) for (const entry of entries) { if (entry.name.startsWith('.') || skippedCacheDirs.has(entry.name)) continue const absolutePath = resolve(currentPath, entry.name) if (!absolutePath.startsWith(rootPath)) continue if (entry.isDirectory()) { folders.push(toRendererModulePath(moduleName, absolutePath, rootPath)) await visit(absolutePath, depth + 1) continue } if (!entry.isFile()) continue const filePath = toRendererModulePath(moduleName, absolutePath, rootPath) const fileStat = await stat(absolutePath) if (!textFilePattern.test(entry.name) || fileStat.size > 1024 * 1024) { files[filePath] = 'Binary or large file preview is not available.' continue } files[filePath] = await readFile(absolutePath, 'utf8') } } await visit(rootPath, 0) return { root: basename(rootPath), absoluteRoot: rootPath, files, folders } } async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null): Promise { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const skippedNames = Array.from(skippedCacheDirs).map(quoteShellSingle).join(' ') const script = [ 'set +e', `ROOT=${quoteShellSingle(moduleRoot)}`, `SKIPPED="${skippedNames}"`, 'find "$ROOT" -mindepth 1 -maxdepth 10 \\( -name .git -o -name build -o -name install -o -name log -o -name node_modules -o -name docker \\) -prune -o -type d -printf "D\\t%P\\n" -o -type f -printf "F\\t%P\\t%s\\n" | while IFS="$(printf \'\\t\')" read -r KIND REL SIZE; do', ' [ -z "$REL" ] && continue', ' NAME="${REL##*/}"', ' case "$KIND" in', ' D) printf "D\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" ;;', ' F)', ' CONTENT="QmluYXJ5IG9yIGxhcmdlIGZpbGUgcHJldmlldyBpcyBub3QgYXZhaWxhYmxlLg=="', ' if [ "${SIZE:-0}" -le 1048576 ]; then', ' case "$NAME" in', ' *.c|*.cc|*.cpp|*.cxx|*.h|*.hpp|*.hh|*.py|*.xml|*.yaml|*.yml|*.json|*.txt|*.md|*.cmake|*.launch|*.sh|*.bash|*.toml|*.ini|*.cfg|*.rviz) CONTENT="$(base64 -w0 "$ROOT/$REL" 2>/dev/null)" ;;', ' esac', ' fi', ' printf "F\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$CONTENT"', ' ;;', ' esac', 'done' ].join('\n') const output = await runSshOutput(target, script, 30000) const files: Record = {} const folders: string[] = [] for (const line of output.split('\n')) { if (!line.trim()) continue const [kind, encodedPath = '', encodedContent = ''] = line.split('\t') const relativePath = decodeRemoteField(encodedPath) if (!relativePath) continue const rendererPath = `modules/${moduleName}/${relativePath}` if (kind === 'D') folders.push(rendererPath) if (kind === 'F') files[rendererPath] = decodeRemoteField(encodedContent) } return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot, files, folders } } async function collectRootFiles(rootPath: string, rendererRoot: string): Promise { const files: Record = {} const folders: string[] = [] const visit = async (currentPath: string, depth: number): Promise => { if (depth > 10) return const entries = await readdir(currentPath, { withFileTypes: true }) for (const entry of entries) { if (entry.name.startsWith('.') || skippedCacheDirs.has(entry.name)) continue const absolutePath = resolve(currentPath, entry.name) if (!absolutePath.startsWith(rootPath)) continue if (entry.isDirectory()) { folders.push(toRendererRootPath(rendererRoot, absolutePath, rootPath)) await visit(absolutePath, depth + 1) continue } if (!entry.isFile()) continue const filePath = toRendererRootPath(rendererRoot, absolutePath, rootPath) const fileStat = await stat(absolutePath) if (!textFilePattern.test(entry.name) || fileStat.size > 1024 * 1024) { files[filePath] = 'Binary or large file preview is not available.' continue } files[filePath] = await readFile(absolutePath, 'utf8') } } await visit(rootPath, 0) return { root: basename(rootPath), absoluteRoot: rootPath, files, folders } } async function resolveRendererModulePath(moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null): Promise { const moduleRoot = await findModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) throw new Error('Module is not installed in workspace') const rendererRoot = `modules/${moduleName}` const relativePath = rendererPath === rendererRoot ? '' : rendererPath.startsWith(`${rendererRoot}/`) ? rendererPath.slice(rendererRoot.length + 1) : rendererPath const absolutePath = resolve(moduleRoot, relativePath) const relativeToRoot = relative(moduleRoot, absolutePath) if (relativeToRoot.startsWith('..') || relativeToRoot === '..' || resolve(relativeToRoot) === relativeToRoot) { throw new Error('Invalid module path') } return absolutePath } async function resolveRemoteRendererModulePath(targetInput: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null): Promise<{ target: ReturnType; moduleRoot: string; relativePath: string; absolutePath: string }> { const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const rendererRoot = `modules/${moduleName}` const relativePath = rendererPath === rendererRoot ? '' : rendererPath.startsWith(`${rendererRoot}/`) ? rendererPath.slice(rendererRoot.length + 1) : rendererPath if (relativePath.split('/').some((part) => part === '..') || relativePath.startsWith('/')) { throw new Error('Invalid module path') } return { target, moduleRoot, relativePath, absolutePath: relativePath ? remotePathJoin(moduleRoot, relativePath) : moduleRoot } } function getTerminalKey(webContentsId: number, sessionId: string): string { return `${webContentsId}:${sessionId}` } function quoteShellArg(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } function buildWorkspaceShellCommand(moduleName: string | undefined, terminalCwd: string, cols: number, rows: number): string { const promptLabel = (moduleName?.trim() || basename(terminalCwd)).replace(/[^a-zA-Z0-9_.-]+/g, '-') const initCommands = [ `stty cols ${cols} rows ${rows}`, `export MODULE_WORKSPACE_ROOT=${quoteShellArg(terminalCwd)}`, `export MODULE_PROMPT_LABEL=${quoteShellArg(promptLabel)}`, `cd ${quoteShellArg(terminalCwd)}`, 'ls() { command ls --color=auto "$@"; }', 'grep() { command grep --color=auto "$@"; }', 'egrep() { command egrep --color=auto "$@"; }', 'fgrep() { command fgrep --color=auto "$@"; }', '__module_workspace_guard() { if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" || "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then return 0; fi; builtin cd "$MODULE_WORKSPACE_ROOT"; printf "\\n모듈 워크스페이스 밖으로 이동할 수 없습니다.\\n"; return 1; }', 'cd() { builtin cd "$@" || return; __module_workspace_guard; }', 'export PROMPT_COMMAND=\'__module_workspace_guard; if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; else __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'', 'export -f ls grep egrep fgrep __module_workspace_guard cd', 'exec /bin/bash --noprofile --norc -i' ] return `/bin/bash --noprofile --norc -c ${quoteShellArg(initCommands.join('; '))}` } function stopTerminal(terminalKey: string): void { const timer = terminalStopTimers.get(terminalKey) if (timer) clearTimeout(timer) terminalStopTimers.delete(terminalKey) const proc = terminalProcesses.get(terminalKey) if (!proc) return intentionalTerminalStops.add(terminalKey) terminalProcesses.delete(terminalKey) if (!proc.killed) proc.kill() } function stopTerminalsForWebContents(webContentsId: number): void { const keyPrefix = `${webContentsId}:` for (const terminalKey of terminalProcesses.keys()) { if (terminalKey.startsWith(keyPrefix)) stopTerminal(terminalKey) } } function scheduleStopTerminal(terminalKey: string): void { const currentTimer = terminalStopTimers.get(terminalKey) if (currentTimer) clearTimeout(currentTimer) const timer = setTimeout(() => stopTerminal(terminalKey), 300) terminalStopTimers.set(terminalKey, timer) } function cancelStopTerminal(terminalKey: string): void { const timer = terminalStopTimers.get(terminalKey) if (!timer) return clearTimeout(timer) terminalStopTimers.delete(terminalKey) } function writeHiddenTerminalCommand(sender: WebContents, sessionId: string, proc: ChildProcessWithoutNullStreams, command: string): void { sender.send('terminal-suppress-data', sessionId, `${command}\r\n`) sender.send('terminal-suppress-data', sessionId, `${command}\n`) proc.stdin.write(`${command}\n`) } async function resolveModuleTerminalRoot(moduleName?: string, workspaceRoot?: string | null, folderName?: string | null): Promise { if (!moduleName) return moduleCacheRoot const moduleRoot = await findModuleRoot(moduleName, workspaceRoot, folderName) if (!moduleRoot) throw new Error('Module is not installed in workspace') return moduleRoot } function registerTerminalHandlers(): void { ipcMain.handle('terminal-start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const webContentsId = event.sender.id const terminalKey = getTerminalKey(webContentsId, sessionId) cancelStopTerminal(terminalKey) const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!terminalCwd) return false await mkdir(terminalCwd, { recursive: true }) const existingProc = terminalProcesses.get(terminalKey) if (existingProc && !existingProc.killed) { return true } const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) const proc = spawn('script', ['-q', '/dev/null', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], { cwd: terminalCwd, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', COLUMNS: String(safeCols), LINES: String(safeRows) } }) terminalProcesses.set(terminalKey, proc) const send = (data: Buffer): void => { if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString()) } proc.stdout.on('data', send) proc.stderr.on('data', send) proc.on('close', (code) => { terminalProcesses.delete(terminalKey) if (intentionalTerminalStops.delete(terminalKey)) return if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`) }) event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId)) return true }) ipcMain.handle('local-workspace:terminal:start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const webContentsId = event.sender.id const terminalKey = getTerminalKey(webContentsId, sessionId) cancelStopTerminal(terminalKey) const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!terminalCwd) return false await mkdir(terminalCwd, { recursive: true }) const existingProc = terminalProcesses.get(terminalKey) if (existingProc && !existingProc.killed) { return true } const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) const proc = spawn('script', ['-q', '/dev/null', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], { cwd: terminalCwd, env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', COLUMNS: String(safeCols), LINES: String(safeRows) } }) terminalProcesses.set(terminalKey, proc) const send = (data: Buffer): void => { if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString()) } proc.stdout.on('data', send) proc.stderr.on('data', send) proc.on('close', (code) => { terminalProcesses.delete(terminalKey) if (intentionalTerminalStops.delete(terminalKey)) return if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`) }) event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId)) return true }) ipcMain.handle('remote-workspace:terminal:start', async (event, targetInput: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', folderName?: string | null) => { const webContentsId = event.sender.id const terminalKey = getTerminalKey(webContentsId, sessionId) cancelStopTerminal(terminalKey) const resolved = await (moduleName ? resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) : Promise.resolve({ target: normalizeRemoteTarget(targetInput), moduleRoot: normalizeRemoteTarget(targetInput).workspaceRoot }) ).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!resolved) return false const existingProc = terminalProcesses.get(terminalKey) if (existingProc && !existingProc.killed) return true const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) const target = normalizeRemoteTarget(resolved.target) const proc = spawn('ssh', [ '-tt', '-p', String(target.port), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=5', '-i', target.identityFile, `${target.user}@${target.host}`, buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows) ], { env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', COLUMNS: String(safeCols), LINES: String(safeRows) } }) terminalProcesses.set(terminalKey, proc) const send = (data: Buffer): void => { if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString()) } proc.stdout.on('data', send) proc.stderr.on('data', send) proc.on('close', (code) => { terminalProcesses.delete(terminalKey) if (intentionalTerminalStops.delete(terminalKey)) return if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`) }) event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId)) return true }) ipcMain.on('terminal-write', (event, data: string, sessionId = 'default') => { terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data) }) ipcMain.on('local-workspace:terminal:write', (event, data: string, sessionId = 'default') => { terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data) }) ipcMain.on('remote-workspace:terminal:write', (event, data: string, sessionId = 'default') => { terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data) }) ipcMain.handle('terminal-change-directory', async (event, moduleName: string, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const terminalKey = getTerminalKey(event.sender.id, sessionId) cancelStopTerminal(terminalKey) const proc = terminalProcesses.get(terminalKey) if (!proc || proc.killed) return false const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!terminalCwd) return false await mkdir(terminalCwd, { recursive: true }) writeHiddenTerminalCommand(event.sender, sessionId, proc, `cd ${quoteShellArg(terminalCwd)}`) return true }) ipcMain.handle('local-workspace:terminal:change-directory', async (event, moduleName: string, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const terminalKey = getTerminalKey(event.sender.id, sessionId) cancelStopTerminal(terminalKey) const proc = terminalProcesses.get(terminalKey) if (!proc || proc.killed) return false const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!terminalCwd) return false await mkdir(terminalCwd, { recursive: true }) writeHiddenTerminalCommand(event.sender, sessionId, proc, `cd ${quoteShellArg(terminalCwd)}`) return true }) ipcMain.handle('remote-workspace:terminal:change-directory', async (event, targetInput: RemoteWorkspaceTarget, moduleName: string, sessionId = 'default', folderName?: string | null) => { const terminalKey = getTerminalKey(event.sender.id, sessionId) cancelStopTerminal(terminalKey) const proc = terminalProcesses.get(terminalKey) if (!proc || proc.killed) return false const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName).catch((error) => { event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`) return null }) if (!resolved) return false writeHiddenTerminalCommand(event.sender, sessionId, proc, `cd ${quoteShellArg(resolved.moduleRoot)}`) return true }) ipcMain.on('terminal-stop', (event, sessionId = 'default') => { scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId)) }) ipcMain.on('local-workspace:terminal:stop', (event, sessionId = 'default') => { scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId)) }) ipcMain.on('remote-workspace:terminal:stop', (event, sessionId = 'default') => { scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId)) }) } if (process.platform === 'linux' && !is.dev) { app.commandLine.appendSwitch('no-sandbox') app.commandLine.appendSwitch('ignore-gpu-blocklist') app.commandLine.appendSwitch('enable-unsafe-swiftshader') } function attachDevToolsShortcut(win: BrowserWindow): void { win.webContents.on('before-input-event', (event, input) => { const key = input.key.toLowerCase() if (input.key === 'F12' || (input.control && input.shift && key === 'i')) { event.preventDefault() win.webContents.toggleDevTools() } }) } function toggleFocusedDevTools(): void { const target = BrowserWindow.getFocusedWindow() if (!target) return target.webContents.toggleDevTools() } function registerDevToolsShortcuts(): void { globalShortcut.register('CommandOrControl+Shift+I', toggleFocusedDevTools) globalShortcut.register('F12', toggleFocusedDevTools) } function createSplashWindow(): void { splashWindow = new BrowserWindow({ width: 580, height: 380, icon: getAppIconPath(), show: false, frame: false, resizable: false, center: true, autoHideMenuBar: true, backgroundColor: '#10131c', webPreferences: { sandbox: false, backgroundThrottling: false } }) splashWindow.once('ready-to-show', () => splashWindow?.show()) splashWindow.on('closed', () => { splashWindow = null }) splashWindow.loadFile(join(__dirname, '../renderer/splash.html')).catch((error) => { console.error('[splash] load failed', error) }) } function createWindow(): void { const { width, height } = screen.getPrimaryDisplay().workAreaSize mainWindow = new BrowserWindow({ width: Math.min(1600, Math.floor(width * 0.92)), height: Math.min(940, Math.floor(height * 0.92)), minWidth: 1180, minHeight: 760, title: 'Autonomy Studio LMS', icon: getAppIconPath(), show: false, frame: false, autoHideMenuBar: true, backgroundColor: '#0a0b0e', webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, devTools: true } }) attachDevToolsShortcut(mainWindow) mainWindow.once('ready-to-show', () => { setTimeout(() => { splashWindow?.close() splashWindow = null mainWindow?.show() }, 1100) }) mainWindow.on('closed', () => { mainWindow = null }) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } 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) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl)) 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) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)) ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers()) ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)) ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) 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) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl)) ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId)) ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, folderName)) ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules)) ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => resetRemoteWorkspaceSession(target, modules)) ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId)) ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, folderName)) ipcMain.handle('remote-workspace:git:status', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null) => readRemoteWorkspaceGitStatus(target, moduleName, folderName, selectedFilePath)) ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId)) ipcMain.handle('remote-workspace:git:restore-file', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreRemoteWorkspaceGitFile(target, moduleName, folderName, filePath, status)) ipcMain.handle('remote-workspace:git:submissions', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => readRemoteWorkspaceGitSubmissions(target, modules)) ipcMain.handle('remote-workspace:git:commit-diff', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null) => readRemoteWorkspaceGitCommitDiff(target, moduleName, folderName, commitHash)) ipcMain.handle('remote-workspace:module-files:read', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => collectRemoteModuleFiles(target, moduleName, folderName)) ipcMain.handle('remote-workspace:module-files:create', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, kind: 'file' | 'folder', folderName?: string | null) => { const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName) const command = kind === 'folder' ? `mkdir -p ${quoteShellSingle(resolved.absolutePath)}` : `mkdir -p ${quoteShellSingle(resolved.absolutePath.split('/').slice(0, -1).join('/'))} && : > ${quoteShellSingle(resolved.absolutePath)}` await runSsh(resolved.target, command, 10000) return { ok: true } }) ipcMain.handle('remote-workspace:module-files:write', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, content: string, folderName?: string | null) => { const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName) await runSsh(resolved.target, `printf %s ${quoteShellSingle(encodeBase64(content))} | base64 -d > ${quoteShellSingle(resolved.absolutePath)}`, 15000) return { ok: true } }) ipcMain.handle('remote-workspace:module-files:copy', async (_event, target: RemoteWorkspaceTarget, moduleName: string, fromRendererPath: string, toRendererPath: string, mode: 'copy' | 'cut', folderName?: string | null) => { const source = await resolveRemoteRendererModulePath(target, moduleName, fromRendererPath, folderName) const destination = await resolveRemoteRendererModulePath(target, moduleName, toRendererPath, folderName) const command = `${mode === 'cut' ? 'mv' : 'cp -a'} ${quoteShellSingle(source.absolutePath)} ${quoteShellSingle(destination.absolutePath)}` await runSsh(source.target, command, 20000) return { ok: true } }) ipcMain.handle('remote-workspace:module-files:rename', async (_event, target: RemoteWorkspaceTarget, moduleName: string, fromRendererPath: string, toRendererPath: string, folderName?: string | null) => { const source = await resolveRemoteRendererModulePath(target, moduleName, fromRendererPath, folderName) const destination = await resolveRemoteRendererModulePath(target, moduleName, toRendererPath, folderName) await runSsh(source.target, `mv ${quoteShellSingle(source.absolutePath)} ${quoteShellSingle(destination.absolutePath)}`, 10000) return { ok: true } }) ipcMain.handle('remote-workspace:module-files:delete', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null) => { const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName) await runSsh(resolved.target, `rm -rf ${quoteShellSingle(resolved.absolutePath)}`, 15000) return { ok: true } }) ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus()) ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile)) ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile)) ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath)) ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId)) ipcMain.handle('local-workspace:git:restore-file', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreWorkspaceGitFile(moduleName, workspaceRoot, folderName, filePath, status)) ipcMain.handle('local-workspace:git:submissions', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => readWorkspaceGitSubmissions(modules, workspaceRoot)) ipcMain.handle('local-workspace:git:commit-diff', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, commitHash?: string | null) => readWorkspaceGitCommitDiff(moduleName, workspaceRoot, folderName, commitHash)) ipcMain.handle('local-workspace:module-files:read', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => collectModuleFiles(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:module-files:create', async (_event, moduleName: string, rendererPath: string, kind: 'file' | 'folder', workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) if (kind === 'folder') { await mkdir(targetPath, { recursive: true }) return { ok: true } } await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, '', { flag: 'wx' }).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error }) return { ok: true } }) ipcMain.handle('local-workspace:module-files:write', async (_event, moduleName: string, rendererPath: string, content: string, workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, content, 'utf8') return { ok: true } }) ipcMain.handle('local-workspace:module-files:copy', async (_event, moduleName: string, fromRendererPath: string, toRendererPath: string, mode: 'copy' | 'cut', workspaceRoot?: string | null, folderName?: string | null) => { const fromPath = await resolveRendererModulePath(moduleName, fromRendererPath, workspaceRoot, folderName) const toPath = await resolveRendererModulePath(moduleName, toRendererPath, workspaceRoot, folderName) await mkdir(dirname(toPath), { recursive: true }) await cp(fromPath, toPath, { recursive: true, force: false, errorOnExist: true }) if (mode === 'cut') await rm(fromPath, { recursive: true, force: true }) return { ok: true } }) ipcMain.handle('local-workspace:module-files:rename', async (_event, moduleName: string, fromRendererPath: string, toRendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => { const fromPath = await resolveRendererModulePath(moduleName, fromRendererPath, workspaceRoot, folderName) const toPath = await resolveRendererModulePath(moduleName, toRendererPath, workspaceRoot, folderName) await mkdir(dirname(toPath), { recursive: true }) await rename(fromPath, toPath) return { ok: true } }) ipcMain.handle('local-workspace:module-files:delete', async (_event, moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) await rm(targetPath, { recursive: true, force: true }) return { ok: true } }) } function getYoutubeVideoIdFromEmbedUrl(url: string): string | null { try { const parsed = new URL(url) const host = parsed.hostname.replace(/^www\./, '') const segments = parsed.pathname.split('/').filter(Boolean) const allowed = parsed.protocol === 'https:' && host === 'youtube.com' && segments[0] === 'embed' && /^[A-Za-z0-9_-]{6,}$/.test(segments[1] ?? '') return allowed ? segments[1] : null } catch { return null } } function openLearningVideoWindow(owner: BrowserWindow | null, input: LearningVideoInput): { ok: boolean; message?: string } { const url = input.url?.trim() ?? '' const videoId = getYoutubeVideoIdFromEmbedUrl(url) if (!videoId) return { ok: false, message: '지원하지 않는 동영상 URL입니다.' } const watchUrl = `https://www.youtube.com/watch?v=${videoId}` const videoWindow = new BrowserWindow({ width: 1080, height: 680, minWidth: 720, minHeight: 480, title: input.title?.trim() || '동영상강의', icon: getAppIconPath(), parent: owner ?? undefined, modal: false, autoHideMenuBar: true, backgroundColor: '#0a0b0e', webPreferences: { sandbox: true, nodeIntegration: false, contextIsolation: true, backgroundThrottling: false } }) videoWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) videoWindow.loadURL(watchUrl).catch((error) => { console.error('[learning-video] load failed', error) }) return { ok: true } } app.whenReady().then(() => { registerDevToolsShortcuts() registerTerminalHandlers() registerLocalWorkspaceHandlers() ipcMain.on('titlebar-minimize', (event) => BrowserWindow.fromWebContents(event.sender)?.minimize()) ipcMain.on('titlebar-maximize', (event) => { const target = BrowserWindow.fromWebContents(event.sender) if (!target) return target.isMaximized() ? target.unmaximize() : target.maximize() }) ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close()) ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) ipcMain.handle('modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl)) ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)) ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers()) ipcMain.handle('modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)) ipcMain.handle('modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) ipcMain.handle('visualization:status', () => readVisualizationBridgeStatus()) ipcMain.handle('visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile)) ipcMain.handle('visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile)) ipcMain.handle('git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath)) ipcMain.handle('git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId)) ipcMain.handle('git:submissions', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => readWorkspaceGitSubmissions(modules, workspaceRoot)) ipcMain.handle('git:commit-diff', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, commitHash?: string | null) => readWorkspaceGitCommitDiff(moduleName, workspaceRoot, folderName, commitHash)) ipcMain.handle('module-files:read', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => collectModuleFiles(moduleName, workspaceRoot, folderName)) ipcMain.handle('example-code:read', () => collectRootFiles(exampleCodeRoot, 'examples/example-code')) ipcMain.handle('module-files:create', async (_event, moduleName: string, rendererPath: string, kind: 'file' | 'folder', workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) if (kind === 'folder') { await mkdir(targetPath, { recursive: true }) return { ok: true } } await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, '', { flag: 'wx' }).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error }) return { ok: true } }) ipcMain.handle('module-files:write', async (_event, moduleName: string, rendererPath: string, content: string, workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) await mkdir(dirname(targetPath), { recursive: true }) await writeFile(targetPath, content, 'utf8') return { ok: true } }) ipcMain.handle('module-files:copy', async (_event, moduleName: string, fromRendererPath: string, toRendererPath: string, mode: 'copy' | 'cut', workspaceRoot?: string | null, folderName?: string | null) => { const fromPath = await resolveRendererModulePath(moduleName, fromRendererPath, workspaceRoot, folderName) const toPath = await resolveRendererModulePath(moduleName, toRendererPath, workspaceRoot, folderName) await mkdir(dirname(toPath), { recursive: true }) if (mode === 'cut') { await rename(fromPath, toPath) } else { await cp(fromPath, toPath, { recursive: true, errorOnExist: true, force: false }) } return { ok: true } }) ipcMain.handle('module-files:rename', async (_event, moduleName: string, fromRendererPath: string, toRendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => { const fromPath = await resolveRendererModulePath(moduleName, fromRendererPath, workspaceRoot, folderName) const toPath = await resolveRendererModulePath(moduleName, toRendererPath, workspaceRoot, folderName) await mkdir(dirname(toPath), { recursive: true }) await rename(fromPath, toPath) return { ok: true } }) ipcMain.handle('module-files:delete', async (_event, moduleName: string, rendererPath: string, workspaceRoot?: string | null, folderName?: string | null) => { const targetPath = await resolveRendererModulePath(moduleName, rendererPath, workspaceRoot, folderName) await rm(targetPath, { recursive: true, force: true }) return { ok: true } }) createSplashWindow() createWindow() }) app.on('window-all-closed', () => app.quit()) app.on('will-quit', () => { globalShortcut.unregisterAll() terminalStopTimers.forEach((timer) => clearTimeout(timer)) terminalStopTimers.clear() terminalProcesses.forEach((_proc, terminalKey) => intentionalTerminalStops.add(terminalKey)) terminalProcesses.forEach((proc) => proc.kill()) terminalProcesses.clear() intentionalTerminalStops.clear() })