diff --git a/package-lock.json b/package-lock.json index 0aa1306..350da3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "autonomy-studio-launcher", - "version": "1.0.14", + "version": "1.0.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autonomy-studio-launcher", - "version": "1.0.14", + "version": "1.0.27", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@tailwindcss/vite": "^4.2.4", "lucide-react": "^1.14.0", + "node-pty": "^1.1.0", "tailwindcss": "^4.2.4" }, "devDependencies": { @@ -8307,6 +8308,22 @@ "node": ">=10" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.38", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", diff --git a/package.json b/package.json index 775cb70..9f7b1e9 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@electron-toolkit/utils": "^4.0.0", "@tailwindcss/vite": "^4.2.4", "lucide-react": "^1.14.0", + "node-pty": "^1.1.0", "tailwindcss": "^4.2.4" }, "devDependencies": { diff --git a/src/main/index.ts b/src/main/index.ts index a7ea959..c9d2166 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { get as httpGet } from 'http' import { get as httpsGet } from 'https' import { spawn, exec, execFile, ChildProcess } from 'child_process' import { electronApp, optimizer, is } from '@electron-toolkit/utils' +import { LmsWebBridge } from './lms-web-bridge' protocol.registerSchemesAsPrivileged([ { scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } } @@ -12,6 +13,7 @@ protocol.registerSchemesAsPrivileged([ let splash: BrowserWindow | null = null let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null +const lmsWebBridge = new LmsWebBridge() type CommandResult = { ok: boolean @@ -954,6 +956,29 @@ function createWindow(): void { ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus()) ipcMain.handle('clear-launcher-cache', () => clearLauncherCache()) ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url)) + ipcMain.handle('launch-web-app', async (_event, payload: { appName?: string; url?: string }) => { + const rawUrl = payload.url?.trim() + if (!rawUrl) throw new Error('웹 실행 주소가 없습니다.') + + try { + const url = new URL(rawUrl) + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('웹 실행 주소 형식이 올바르지 않습니다.') + } + if (payload.appName?.trim().toLowerCase() === 'lms') { + const bridge = await lmsWebBridge.createSession(url.origin) + const hash = new URLSearchParams(url.hash.replace(/^#/, '')) + hash.set('bridgeUrl', bridge.url) + hash.set('bridgeToken', bridge.token) + url.hash = hash.toString() + } + await shell.openExternal(url.toString()) + mainWindow.minimize() + return { ok: true } + } catch (error) { + throw new Error(error instanceof Error ? error.message : '웹 브라우저를 열지 못했습니다.') + } + }) ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus()) ipcMain.handle('launcher-tailscale-install', () => installTailscale()) ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey()) @@ -1220,4 +1245,8 @@ if (!gotSingleInstanceLock) { app.quit() } }) + + app.on('will-quit', () => { + void lmsWebBridge.stop() + }) } diff --git a/src/main/lms-web-bridge.ts b/src/main/lms-web-bridge.ts new file mode 100644 index 0000000..6f6d40e --- /dev/null +++ b/src/main/lms-web-bridge.ts @@ -0,0 +1,1453 @@ +import { randomBytes } from 'crypto' +import { spawn } from 'child_process' +import * as pty from 'node-pty' +import type { IPty } from 'node-pty' +import { existsSync } from 'fs' +import { cp, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'fs/promises' +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { homedir } from 'os' +import { basename, dirname, relative, resolve } from 'path' + +type CourseModule = { + id?: string + name: string + folderName: string + category?: string + sortOrder?: number + templateRepositoryUrl?: string | null + studentRepositoryUrl?: string | null +} + +type BridgeSession = { + origin: string + expiresAt: number +} + +type TerminalSession = { + clientId: string | null + process: IPty + exited: boolean + output: string + cursor: number + readers: Set<() => void> +} + +type RemoteWorkspaceTarget = { + host: string + user?: string | null + port?: number | null + workspaceRoot?: string | null +} + +type RuntimeImageArtifact = { + image?: string | null + url?: string | null + fileName?: string | null + checksumSha256?: string | null + required?: boolean | null +} + +const BRIDGE_SESSION_TTL_MS = 30 * 60 * 1000 +const DEFAULT_WORKSPACE_ROOT = '~/autonomy-studio/workspaces' +const AUTOWARE_IMAGE = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda' +const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|ya?ml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i +const skippedDirectoryNames = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']) +const studioGitMetadataFile = '.autonomy-studio-git.json' +const workspaceGitIgnoreLines = [ + '# Autonomy Studio generated outputs', + 'build/', + 'install/', + 'log/', + '__pycache__/', + '**/__pycache__/', + '*.py[cod]', + '.pytest_cache/', + '.mypy_cache/', + '.cache/', + studioGitMetadataFile, +] +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)${studioGitMetadataFile}`, +] + +function command(command: string, args: string[], cwd?: string, timeoutMs = 30_000, input?: string): Promise { + return new Promise((resolvePromise, rejectPromise) => { + let output = '' + const child = spawn(command, args, { + cwd, + stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, + }) + const timer = setTimeout(() => { + child.kill('SIGTERM') + rejectPromise(new Error(`${command} timed out`)) + }, timeoutMs) + child.stdout?.on('data', (chunk: Buffer) => { output += chunk.toString() }) + child.stderr?.on('data', (chunk: Buffer) => { output += chunk.toString() }) + if (input !== undefined) child.stdin?.end(input) + child.on('error', (error) => { clearTimeout(timer); rejectPromise(error) }) + child.on('close', (code) => { + clearTimeout(timer) + if (code === 0) resolvePromise(output) + else rejectPromise(new Error(output.trim() || `${command} failed`)) + }) + }) +} + +function expandWorkspaceRoot(value?: string): string { + const raw = value?.trim() || DEFAULT_WORKSPACE_ROOT + const path = raw === '~' ? homedir() : raw.startsWith('~/') ? resolve(homedir(), raw.slice(2)) : resolve(raw) + const home = resolve(homedir()) + if (path !== home && !path.startsWith(`${home}/`)) throw new Error('워크스페이스 경로는 사용자 홈 디렉터리 안에 있어야 합니다.') + return path +} + +function validFolderName(value: unknown): value is string { + return typeof value === 'string' && /^\[[A-Za-z0-9_.-]+\][A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+_ws$/.test(value) +} + +function containerName(folderName: string): string | null { + const match = folderName.match(/^\[([^\]]+)\](.+)_v([^/]+)_ws$/) + if (!match) return null + const name = `${match[1]}-${match[2]}_v${match[3]}` + return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(name) ? name : null +} + +function validRepositoryUrl(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) return false + return /^(https?:\/\/|ssh:\/\/|git@)[A-Za-z0-9_.:@/~-]+(?:\.git)?$/i.test(value.trim()) +} + +function normalizeRendererPath(moduleName: string, value: unknown): string { + if (typeof value !== 'string') throw new Error('파일 경로가 필요합니다.') + const rendererRoot = `modules/${moduleName}` + const path = value === rendererRoot ? '' : value.startsWith(`${rendererRoot}/`) ? value.slice(rendererRoot.length + 1) : value + if (!path || path.startsWith('/') || path.split('/').some((part) => !part || part === '.' || part === '..')) { + if (!path) return '' + throw new Error('파일 경로가 올바르지 않습니다.') + } + return path +} + +function parseGitStatus(output: string) { + const files: Array<{ path: string; status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'; staged: boolean; unstaged: boolean }> = [] + for (const line of output.split('\n')) { + if (!line || line.startsWith('## ')) continue + const index = line[0] ?? ' ' + const worktree = line[1] ?? ' ' + const rawPath = line.slice(3).trim() + if (!rawPath) continue + const path = index === 'R' || worktree === 'R' ? rawPath.split(' -> ').at(-1) ?? rawPath : rawPath + if (isGeneratedWorkspacePath(path)) continue + const status = index === '?' || worktree === '?' ? 'untracked' + : index === 'D' || worktree === 'D' ? 'deleted' + : index === 'R' || worktree === 'R' ? 'renamed' + : index === 'A' || worktree === 'A' ? 'added' : 'modified' + files.push({ path, status, staged: index !== ' ' && index !== '?', unstaged: worktree !== ' ' && worktree !== '?' ? true : index === '?' }) + } + return files +} + +function isGeneratedWorkspacePath(path: string): boolean { + return path === '.gitignore' + || path === studioGitMetadataFile + || path === 'build' + || path === 'install' + || path === 'log' + || path.startsWith('build/') + || path.startsWith('install/') + || path.startsWith('log/') + || path.startsWith('__pycache__/') + || path.includes('/__pycache__/') + || path.startsWith('.pytest_cache/') + || path.startsWith('.mypy_cache/') + || path.startsWith('.cache/') + || /\.(pyc|pyo|pyd)$/i.test(path) +} + +function parseGitBranch(output: string) { + const header = output.split('\n').find((line) => line.startsWith('## ')) ?? '' + const branch = header.slice(3).split('...')[0]?.trim() || null + const ahead = Number(header.match(/ahead (\d+)/)?.[1] ?? 0) + const behind = Number(header.match(/behind (\d+)/)?.[1] ?? 0) + return { branch, ahead, behind } +} + +function parseGitCommits(output: string) { + return output.split('\n').filter(Boolean).map((line) => { + const [hash = '', shortHash = '', author = '', date = '', message = ''] = line.split('\x1f') + return { hash, shortHash, author, date, message } + }).filter((commit) => commit.hash) +} + +function quoteShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +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) => !currentLines.has(line)) + if (!missingLines.length) return + + const prefix = currentContent.trimEnd() + await writeFile(gitIgnorePath, `${prefix}${prefix ? '\n\n' : ''}${missingLines.join('\n')}\n`, 'utf8') +} + +function terminalSize(value: unknown, fallback: number, minimum: number, maximum: number): number { + const number = typeof value === 'number' ? value : fallback + return Math.max(minimum, Math.min(maximum, Math.floor(number))) +} + +function terminalEnvironment(cols: number, rows: number): Record { + return Object.fromEntries( + Object.entries({ + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + COLUMNS: String(cols), + LINES: String(rows), + }).filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ) +} + +function buildWorkspaceShellCommand(moduleName: string | undefined, terminalCwd: string, cols: unknown, rows: unknown): string { + const promptLabel = (moduleName?.trim() || basename(terminalCwd)).replace(/[^a-zA-Z0-9_.-]+/g, '-') + const safeCols = terminalSize(cols, 80, 20, 300) + const safeRows = terminalSize(rows, 24, 5, 120) + const initCommands = [ + `stty cols ${safeCols} rows ${safeRows}`, + `export MODULE_WORKSPACE_ROOT=${quoteShell(terminalCwd)}`, + `export MODULE_PROMPT_LABEL=${quoteShell(promptLabel)}`, + `cd ${quoteShell(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 ${quoteShell(initCommands.join('; '))}` +} + +function buildRemoteWorkspaceShellCommand(moduleName: string | undefined, remotePath: string, cols: unknown, rows: unknown): string { + const promptLabel = (moduleName?.trim() || basename(remotePath)).replace(/[^a-zA-Z0-9_.-]+/g, '-') + const safeCols = terminalSize(cols, 80, 20, 300) + const safeRows = terminalSize(rows, 24, 5, 120) + const initCommands = [ + `MODULE_WORKSPACE_ROOT=${quoteShell(remotePath)}`, + 'case "$MODULE_WORKSPACE_ROOT" in ~/*) MODULE_WORKSPACE_ROOT="$HOME/${MODULE_WORKSPACE_ROOT#~/}";; esac', + `export MODULE_PROMPT_LABEL=${quoteShell(promptLabel)}`, + `stty cols ${safeCols} rows ${safeRows}`, + 'cd "$MODULE_WORKSPACE_ROOT"', + 'export MODULE_WORKSPACE_ROOT="$PWD"', + '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 ${quoteShell(initCommands.join('; '))}` +} + +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 [ + '', + '', + ' ', + ' default65500B', + ' auto120', + ' 500kB', + ' ', + '', + '', + ].join('\n') +} + +async function readBody(request: IncomingMessage): Promise> { + const chunks: Buffer[] = [] + let size = 0 + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + size += buffer.length + if (size > 256 * 1024) throw new Error('요청 본문이 너무 큽니다.') + chunks.push(buffer) + } + if (!chunks.length) return {} + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('요청 형식이 올바르지 않습니다.') + return value as Record +} + +function respond(response: ServerResponse, status: number, value: unknown, origin?: string) { + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + ...(origin ? { 'access-control-allow-origin': origin, 'access-control-allow-headers': 'content-type, x-autonomy-lms-bridge-token', 'access-control-allow-methods': 'GET, POST, OPTIONS', vary: 'Origin' } : {}), + }) + response.end(JSON.stringify(value)) +} + +export class LmsWebBridge { + private server: ReturnType | null = null + private port = 0 + private sessions = new Map() + private terminals = new Map() + + async createSession(origin: string) { + await this.ensureStarted() + const token = randomBytes(32).toString('base64url') + this.sessions.set(token, { origin, expiresAt: Date.now() + BRIDGE_SESSION_TTL_MS }) + return { url: `http://127.0.0.1:${this.port}`, token, expiresAt: Date.now() + BRIDGE_SESSION_TTL_MS } + } + + async stop() { + if (!this.server) return + await new Promise((resolvePromise) => this.server?.close(() => resolvePromise())) + this.server = null + this.port = 0 + this.sessions.clear() + for (const terminal of this.terminals.values()) terminal.process.kill() + this.terminals.clear() + } + + private async ensureStarted() { + if (this.server) return + this.server = createServer((request, response) => { void this.handle(request, response) }) + await new Promise((resolvePromise, rejectPromise) => { + this.server?.once('error', rejectPromise) + this.server?.listen(0, '127.0.0.1', () => { + const address = this.server?.address() + if (!address || typeof address === 'string') return rejectPromise(new Error('LMS 웹 브리지 포트를 확인하지 못했습니다.')) + this.port = address.port + this.server?.off('error', rejectPromise) + resolvePromise() + }) + }) + } + + private getSession(request: IncomingMessage): BridgeSession | null { + const token = request.headers['x-autonomy-lms-bridge-token'] + const value = Array.isArray(token) ? token[0] : token + if (!value) return null + const session = this.sessions.get(value) + if (!session || session.expiresAt <= Date.now()) { + this.sessions.delete(value) + return null + } + return session + } + + private async handle(request: IncomingMessage, response: ServerResponse) { + const origin = request.headers.origin + if (request.method === 'OPTIONS') { + const allowedOrigin = typeof origin === 'string' + && Array.from(this.sessions.values()).some((session) => session.origin === origin && session.expiresAt > Date.now()) + if (!allowedOrigin) { + respond(response, 401, { message: 'LMS 웹 브리지 인증이 필요합니다.' }) + return + } + respond(response, 204, {}, origin) + return + } + + const session = this.getSession(request) + if (!session || origin !== session.origin) { + respond(response, 401, { message: 'LMS 웹 브리지 인증이 필요합니다.' }) + return + } + + try { + const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + if (request.method === 'GET' && path === '/health') { + const dockerAvailable = await command('docker', ['version', '--format', '{{.Server.Version}}'], undefined, 5_000) + .then((version) => ({ available: true, version: version.trim() })) + .catch(() => ({ available: false, version: null })) + respond(response, 200, { ok: true, docker: dockerAvailable, defaultWorkspaceRoot: DEFAULT_WORKSPACE_ROOT }, session.origin) + return + } + + if (request.method !== 'POST') throw new Error('지원하지 않는 요청입니다.') + const body = await readBody(request) + if (path === '/api/modules/list') { + respond(response, 200, await this.listModules(body), session.origin) + return + } + if (path === '/api/modules/install') { + respond(response, 200, await this.installModule(body), session.origin) + return + } + if (path === '/api/modules/start') { + respond(response, 200, await this.startModule(body), session.origin) + return + } + if (path === '/api/modules/build') { + respond(response, 200, await this.buildModule(body), session.origin) + return + } + if (path === '/api/modules/stop') { + respond(response, 200, await this.stopModule(body), session.origin) + return + } + if (path === '/api/modules/logs') { + respond(response, 200, await this.moduleLogs(body), session.origin) + return + } + if (path === '/api/modules/delete') { + respond(response, 200, await this.deleteModule(body), session.origin) + return + } + if (path === '/api/modules/stop-running') { + respond(response, 200, await this.stopRunningModules(), session.origin) + return + } + if (path === '/api/modules/update-parameters') { + respond(response, 200, await this.updateModuleParameters(body), session.origin) + return + } + if (path === '/api/files/read') { + respond(response, 200, await this.readModuleFiles(body), session.origin) + return + } + if (path === '/api/files/create') { + respond(response, 200, await this.createModuleFile(body), session.origin) + return + } + if (path === '/api/files/write') { + respond(response, 200, await this.writeModuleFile(body), session.origin) + return + } + if (path === '/api/files/copy') { + respond(response, 200, await this.copyModuleFile(body), session.origin) + return + } + if (path === '/api/files/rename') { + respond(response, 200, await this.renameModuleFile(body), session.origin) + return + } + if (path === '/api/files/delete') { + respond(response, 200, await this.deleteModuleFile(body), session.origin) + return + } + if (path === '/api/git/status') { + respond(response, 200, await this.gitStatus(body), session.origin) + return + } + if (path === '/api/git/submit') { + respond(response, 200, await this.gitSubmit(body), session.origin) + return + } + if (path === '/api/git/restore') { + respond(response, 200, await this.gitRestore(body), session.origin) + return + } + if (path === '/api/git/submissions') { + respond(response, 200, await this.gitSubmissions(body), session.origin) + return + } + if (path === '/api/git/commit-diff') { + respond(response, 200, await this.gitCommitDiff(body), session.origin) + return + } + if (path === '/api/terminal/start') { + respond(response, 200, await this.startTerminal(body), session.origin) + return + } + if (path === '/api/terminal/change-directory') { + respond(response, 200, await this.changeTerminalDirectory(body), session.origin) + return + } + if (path === '/api/terminal/resize') { + respond(response, 200, this.resizeTerminal(body), session.origin) + return + } + if (path === '/api/terminal/write') { + respond(response, 200, this.writeTerminal(body), session.origin) + return + } + if (path === '/api/terminal/read') { + respond(response, 200, await this.readTerminal(body), session.origin) + return + } + if (path === '/api/terminal/stop') { + respond(response, 200, this.stopTerminal(body), session.origin) + return + } + if (path === '/api/remote/files/read') { + respond(response, 200, await this.readRemoteFiles(body), session.origin) + return + } + if (path === '/api/remote/files/create') { + respond(response, 200, await this.remoteFileMutation(body, 'create'), session.origin) + return + } + if (path === '/api/remote/files/write') { + respond(response, 200, await this.remoteFileMutation(body, 'write'), session.origin) + return + } + if (path === '/api/remote/files/copy') { + respond(response, 200, await this.remoteFileMutation(body, 'copy'), session.origin) + return + } + if (path === '/api/remote/files/rename') { + respond(response, 200, await this.remoteFileMutation(body, 'rename'), session.origin) + return + } + if (path === '/api/remote/files/delete') { + respond(response, 200, await this.remoteFileMutation(body, 'delete'), session.origin) + return + } + if (path === '/api/remote/git/status') { + respond(response, 200, await this.remoteGitStatus(body), session.origin) + return + } + if (path === '/api/remote/git/submit') { + respond(response, 200, await this.remoteGitSubmit(body), session.origin) + return + } + if (path === '/api/remote/git/restore') { + respond(response, 200, await this.remoteGitRestore(body), session.origin) + return + } + if (path === '/api/remote/git/commit-diff') { + respond(response, 200, await this.remoteGitCommitDiff(body), session.origin) + return + } + if (path === '/api/remote/modules/list') { + respond(response, 200, await this.listRemoteModules(body), session.origin) + return + } + if (path === '/api/remote/modules/install') { + respond(response, 200, await this.installRemoteModule(body), session.origin) + return + } + if (path === '/api/remote/modules/start') { + respond(response, 200, await this.startRemoteModule(body), session.origin) + return + } + if (path === '/api/remote/modules/build') { + respond(response, 200, await this.buildRemoteModule(body), session.origin) + return + } + if (path === '/api/remote/modules/stop') { + respond(response, 200, await this.stopRemoteModule(body), session.origin) + return + } + if (path === '/api/remote/modules/logs') { + respond(response, 200, await this.remoteModuleLogs(body), session.origin) + return + } + if (path === '/api/remote/terminal/start') { + respond(response, 200, await this.startRemoteTerminal(body), session.origin) + return + } + if (path === '/api/remote/terminal/change-directory') { + respond(response, 200, await this.changeRemoteTerminalDirectory(body), session.origin) + return + } + if (path === '/api/remote/terminal/resize') { + respond(response, 200, this.resizeRemoteTerminal(body), session.origin) + return + } + if (path === '/api/remote/terminal/write') { + respond(response, 200, this.writeRemoteTerminal(body), session.origin) + return + } + if (path === '/api/remote/terminal/read') { + respond(response, 200, await this.readRemoteTerminal(body), session.origin) + return + } + if (path === '/api/remote/terminal/stop') { + respond(response, 200, this.stopRemoteTerminal(body), session.origin) + return + } + throw new Error('지원하지 않는 요청입니다.') + } catch (error) { + respond(response, 400, { message: error instanceof Error ? error.message : 'LMS 웹 브리지 요청에 실패했습니다.' }, session.origin) + } + } + + private moduleFromBody(body: Record) { + const module = body.module + if (!module || typeof module !== 'object' || Array.isArray(module)) throw new Error('모듈 정보가 필요합니다.') + const value = module as CourseModule + if (!value.name?.trim() || !validFolderName(value.folderName)) throw new Error('모듈 정보 형식이 올바르지 않습니다.') + return value + } + + private moduleRoot(body: Record) { + const module = this.moduleFromBody(body) + const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined) + const target = resolve(root, module.folderName) + if (!target.startsWith(`${root}/`)) throw new Error('모듈 경로가 올바르지 않습니다.') + return { module, root, target } + } + + private moduleFilePath(body: Record, key: string) { + const { module, target } = this.moduleRoot(body) + const relativePath = normalizeRendererPath(module.name, body[key]) + const absolutePath = relativePath ? resolve(target, relativePath) : target + if (absolutePath !== target && !absolutePath.startsWith(`${target}/`)) throw new Error('파일 경로가 올바르지 않습니다.') + return { module, target, relativePath, absolutePath } + } + + private remoteTarget(body: Record): RemoteWorkspaceTarget { + const raw = body.target + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('원격 플랫폼 정보가 필요합니다.') + const value = raw as RemoteWorkspaceTarget + const host = value.host?.trim() + const user = value.user?.trim() || process.env.USER || '' + const port = Number(value.port ?? 22) + const workspaceRoot = value.workspaceRoot?.trim() + if (!host || !/^[A-Za-z0-9.:_-]+$/.test(host)) throw new Error('원격 플랫폼 주소가 올바르지 않습니다.') + if (!/^[A-Za-z0-9_.-]+$/.test(user)) throw new Error('원격 SSH 사용자가 올바르지 않습니다.') + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('원격 SSH 포트가 올바르지 않습니다.') + if (!workspaceRoot || workspaceRoot.includes('\0') || workspaceRoot.includes('..')) throw new Error('원격 워크스페이스 경로가 올바르지 않습니다.') + return { host, user, port, workspaceRoot } + } + + private ssh(target: RemoteWorkspaceTarget, script: string, timeoutMs = 30_000) { + return command('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=8', '-p', String(target.port ?? 22), `${target.user}@${target.host}`, 'bash', '-s'], undefined, timeoutMs, script) + } + + private async dockerStatuses() { + const output = await command('docker', ['ps', '-a', '--format', '{{.Names}}\t{{.Status}}'], undefined, 8_000).catch(() => '') + return new Map(output.split('\n').filter(Boolean).map((line) => { + const [name, status = ''] = line.split('\t') + return [name, { exists: true, running: /^Up\b/i.test(status), status }] + })) + } + + private async dockerStats() { + const output = await command('docker', ['stats', '--no-stream', '--format', '{{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}'], undefined, 10_000).catch(() => '') + return new Map(output.split('\n').filter(Boolean).map((line) => { + const [name, cpu = '', memory = ''] = line.split('\t') + const parsePercent = (value: string): number | null => { + const parsed = Number(value.replace('%', '').trim()) + return Number.isFinite(parsed) ? parsed : null + } + return [name, { cpu: parsePercent(cpu), memory: parsePercent(memory) }] + })) + } + + private formatRuntime(status: string): string { + const match = status.match(/^Up\s+(.+?)(?:\s+\(|$)/i) + const runtime = match?.[1]?.trim() + if (!runtime) return '실행 중' + if (runtime.toLowerCase() === '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() + } + + private async listModules(body: Record) { + const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined) + const requested = Array.isArray(body.modules) ? body.modules.slice(0, 100) : [] + const statuses = await this.dockerStatuses() + const stats = await this.dockerStats() + const modules = await Promise.all(requested.map(async (raw, index) => { + const module = raw as CourseModule + if (!module?.name || !validFolderName(module.folderName)) return null + const workspacePath = resolve(root, module.folderName) + const installed = await stat(workspacePath).then((entry) => entry.isDirectory()).catch(() => false) + const container = containerName(module.folderName) + const docker = container ? statuses.get(container) : undefined + const dockerStat = container ? stats.get(container) : undefined + return { + ...module, + sortOrder: Number(module.sortOrder) || index + 1, + workspacePath: installed ? workspacePath : null, + state: docker?.running ? 'running' : installed ? 'stopped' : 'uninstalled', + cpu: docker?.running ? dockerStat?.cpu ?? null : null, + memory: docker?.running ? dockerStat?.memory ?? null : null, + runtime: docker?.running ? this.formatRuntime(docker.status) : '-', + } + })) + return { workspaceRoot: root, modules: modules.filter(Boolean) } + } + + private async installModule(body: Record) { + const module = this.moduleFromBody(body) + const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined) + const target = resolve(root, module.folderName) + if (!target.startsWith(`${root}/`)) throw new Error('모듈 경로가 올바르지 않습니다.') + if (existsSync(target)) return { ok: true, message: '이미 설치되어 있습니다.', workspacePath: target } + if (!validRepositoryUrl(module.templateRepositoryUrl)) throw new Error('템플릿 저장소 주소가 필요합니다.') + await mkdir(root, { recursive: true }) + const studentRepositoryUrl = validRepositoryUrl(module.studentRepositoryUrl) ? module.studentRepositoryUrl.trim() : '' + const hasStudentRepository = studentRepositoryUrl && await command('git', ['ls-remote', '--heads', studentRepositoryUrl], undefined, 15_000).then((value) => Boolean(value.trim())).catch(() => false) + const cloneUrl = hasStudentRepository ? studentRepositoryUrl : module.templateRepositoryUrl.trim() + await command('git', ['clone', cloneUrl, target], undefined, 120_000) + await ensureWorkspaceGitIgnore(target) + if (!hasStudentRepository && studentRepositoryUrl) { + await command('git', ['remote', 'set-url', 'origin', studentRepositoryUrl], target, 15_000) + await command('git', ['push', '-u', 'origin', 'HEAD:main'], target, 60_000) + } + return { ok: true, message: '모듈을 설치했습니다.', workspacePath: target } + } + + private async startModule(body: Record) { + const module = this.moduleFromBody(body) + const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined) + const target = resolve(root, module.folderName) + const runtimeConfigRoot = resolve(homedir(), 'autonomy-studio', 'runtime', 'config') + if (!existsSync(target)) throw new Error('설치된 모듈을 찾을 수 없습니다.') + const container = containerName(module.folderName) + if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.') + const statuses = await this.dockerStatuses() + if (statuses.get(container)?.running) return { ok: true, message: '이미 실행 중입니다.' } + if (statuses.get(container)?.exists) await command('docker', ['rm', '-f', container], undefined, 15_000).catch(() => undefined) + const rosParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : '' + const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0' + const gpuEnabled = body.gpuEnabled === true + await mkdir(runtimeConfigRoot, { recursive: true }) + const args = ['run', '-d', '--name', container, '--network', 'host', '-v', `${target}:/autoware:rw`, '-v', `${runtimeConfigRoot}:/autoware/config:rw`, '-v', '/etc/localtime:/etc/localtime:ro', '-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-e', 'RCUTILS_COLORIZED_OUTPUT=1', '-e', 'ROS_LOCALHOST_ONLY=1'] + if (process.env.DISPLAY) args.push('-e', `DISPLAY=${process.env.DISPLAY}`, '-v', '/tmp/.X11-unix:/tmp/.X11-unix') + if (gpuEnabled) args.push('--gpus', 'all', '-e', 'NVIDIA_VISIBLE_DEVICES=all', '-e', 'NVIDIA_DRIVER_CAPABILITIES=graphics,compute,utility,display') + args.push('--entrypoint', '/bin/bash', AUTOWARE_IMAGE, '-lc', 'cd /autoware && bash /autoware/run.sh "$@"', '_', rosParams) + await command('docker', args, undefined, 45_000) + return { ok: true, message: '실행 요청을 보냈습니다.' } + } + + private async buildModule(body: Record) { + const module = this.moduleFromBody(body) + const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined) + const target = resolve(root, module.folderName) + if (!existsSync(target)) throw new Error('설치된 모듈을 찾을 수 없습니다.') + const container = containerName(module.folderName) + if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.') + const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0' + await command('docker', [ + 'run', '--rm', '--name', `${container}-build`, '--volume', `${target}:/autoware:rw`, '-u', 'root', '--net=host', + '-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-v', '/etc/localtime:/etc/localtime:ro', '--entrypoint', '/bin/bash', AUTOWARE_IMAGE, + '-lc', 'cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install' + ], undefined, 900_000) + return { ok: true, message: '빌드가 완료되었습니다.' } + } + + private async stopModule(body: Record) { + const module = this.moduleFromBody(body) + const container = containerName(module.folderName) + if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.') + const statuses = await this.dockerStatuses() + const buildContainer = `${container}-build` + const build = statuses.get(buildContainer) + if (build?.exists) await command('docker', ['rm', '-f', buildContainer], undefined, 30_000).catch(() => undefined) + if (!statuses.get(container)?.exists) return { ok: true, message: build?.exists ? '빌드를 중지했습니다.' : '중지할 컨테이너가 없습니다.' } + if (statuses.get(container)?.running) await command('docker', ['stop', container], undefined, 30_000) + return { ok: true, message: '중지했습니다.' } + } + + private async moduleLogs(body: Record) { + const module = this.moduleFromBody(body) + const container = containerName(module.folderName) + if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.') + const statuses = await this.dockerStatuses() + if (!statuses.get(container)?.exists) return { ok: true, logs: '', message: '생성된 컨테이너가 없습니다.' } + const logs = await command('docker', ['logs', '--timestamps', '--tail', '300', container], undefined, 10_000).catch((error) => error instanceof Error ? error.message : '') + return { ok: true, logs } + } + + private async deleteModule(body: Record) { + const { target } = this.moduleRoot(body) + await rm(target, { recursive: true, force: true }) + return { ok: true } + } + + private async stopRunningModules() { + const statuses = await this.dockerStatuses() + const running = Array.from(statuses.entries()) + .filter(([name, value]) => /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(name) && value.running) + .map(([name]) => name) + await Promise.all(running.map((name) => command('docker', ['stop', name], undefined, 30_000).catch(() => undefined))) + return { ok: true, message: `${running.length}개 모듈을 중지했습니다.` } + } + + private async updateModuleParameters(body: Record) { + const { target } = this.moduleRoot(body) + const rosParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : '' + const parameters = rosParams ? rosParams.split(/\s+/) : [] + const runScript = resolve(target, 'run.sh') + if (!existsSync(runScript)) throw new Error('모듈 실행 스크립트를 찾을 수 없습니다.') + const current = await readFile(runScript, 'utf8') + const rosParamLine = `ROS_PARAM='${rosParams.replace(/'/g, "'\\''")}'` + const next = /^ROS_PARAM=.*$/m.test(current) + ? current.replace(/^ROS_PARAM=.*$/m, rosParamLine) + : /^RUN_CMD=.*$/m.test(current) + ? current.replace(/^RUN_CMD=.*$/m, (line) => `${line}\n${rosParamLine}`) + : `${current.trimEnd()}\n${rosParamLine}\n` + await writeFile(runScript, next, 'utf8') + return { ok: true, parameters } + } + + private async readModuleFiles(body: Record) { + const { module, target } = this.moduleRoot(body) + if (!existsSync(target)) return { root: null, absoluteRoot: null, files: {}, folders: [] } + const files: Record = {} + const folders: string[] = [] + const visit = async (current: string, depth: number): Promise => { + if (depth > 10) return + const entries = await readdir(current, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name.startsWith('.') || skippedDirectoryNames.has(entry.name)) continue + const absolute = resolve(current, entry.name) + if (!absolute.startsWith(`${target}/`)) continue + const rendererPath = `modules/${module.name}/${relative(target, absolute)}` + if (entry.isDirectory()) { + folders.push(rendererPath) + await visit(absolute, depth + 1) + } else if (entry.isFile()) { + const details = await stat(absolute) + files[rendererPath] = textFilePattern.test(entry.name) && details.size <= 1024 * 1024 + ? await readFile(absolute, 'utf8') + : 'Binary or large file preview is not available.' + } + } + } + await visit(target, 0) + return { root: basename(target), absoluteRoot: target, files, folders } + } + + private async createModuleFile(body: Record) { + const { absolutePath } = this.moduleFilePath(body, 'path') + const kind = body.kind + if (kind === 'folder') await mkdir(absolutePath, { recursive: false }) + else if (kind === 'file') { + await mkdir(dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, '', { encoding: 'utf8', flag: 'wx' }) + } else throw new Error('생성 유형이 올바르지 않습니다.') + return { ok: true } + } + + private async writeModuleFile(body: Record) { + const { absolutePath } = this.moduleFilePath(body, 'path') + if (typeof body.content !== 'string') throw new Error('파일 내용이 필요합니다.') + if (Buffer.byteLength(body.content, 'utf8') > 1024 * 1024) throw new Error('파일 내용이 너무 큽니다.') + await writeFile(absolutePath, body.content, 'utf8') + return { ok: true } + } + + private async copyModuleFile(body: Record) { + const source = this.moduleFilePath(body, 'fromPath') + const destination = this.moduleFilePath(body, 'toPath') + if (!destination.absolutePath.startsWith(`${source.target}/`)) throw new Error('파일 경로가 올바르지 않습니다.') + await mkdir(dirname(destination.absolutePath), { recursive: true }) + await cp(source.absolutePath, destination.absolutePath, { recursive: true, errorOnExist: true, force: false }) + if (body.mode === 'cut') await rm(source.absolutePath, { recursive: true, force: true }) + return { ok: true } + } + + private async renameModuleFile(body: Record) { + const source = this.moduleFilePath(body, 'fromPath') + const destination = this.moduleFilePath(body, 'toPath') + await mkdir(dirname(destination.absolutePath), { recursive: true }) + await rename(source.absolutePath, destination.absolutePath) + return { ok: true } + } + + private async deleteModuleFile(body: Record) { + const { target, absolutePath } = this.moduleFilePath(body, 'path') + if (absolutePath === target) throw new Error('모듈 루트는 삭제할 수 없습니다.') + await rm(absolutePath, { recursive: true, force: true }) + return { ok: true } + } + + private async gitStatus(body: Record) { + try { + const { target } = this.moduleRoot(body) + await ensureWorkspaceGitIgnore(target) + const statusOutput = await command('git', ['status', '--porcelain=v1', '-b', '-uall', ...workspaceGitPathspec], target, 10_000) + const { branch, ahead, behind } = parseGitBranch(statusOutput) + const files = parseGitStatus(statusOutput) + const selectedFilePath = typeof body.selectedFilePath === 'string' && files.some((file) => file.path === body.selectedFilePath) + ? body.selectedFilePath : files[0]?.path ?? null + let diff = '' + if (selectedFilePath) { + diff = await command('git', ['diff', '--no-ext-diff', '--', selectedFilePath], target, 10_000).catch(() => '') + if (!diff) diff = await command('git', ['diff', '--cached', '--no-ext-diff', '--', selectedFilePath], target, 10_000).catch(() => '') + } + const commits = parseGitCommits(await command('git', ['log', '--max-count=20', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict'], target, 10_000).catch(() => '')) + return { ok: true, branch, ahead, behind, files, selectedFilePath, diff, commits } + } catch (error) { + return { ok: false, branch: null, ahead: 0, behind: 0, files: [], selectedFilePath: null, diff: '', commits: [], message: error instanceof Error ? error.message : String(error) } + } + } + + private async gitSubmit(body: Record) { + try { + const { module, target } = this.moduleRoot(body) + await ensureWorkspaceGitIgnore(target) + const statusOutput = await command('git', ['status', '--porcelain=v1', '-uall', ...workspaceGitPathspec], target, 10_000) + const files = parseGitStatus(statusOutput) + if (!files.length) return { ok: false, message: '제출할 변경사항이 없습니다.' } + const message = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : `${module.name} 제출` + const authorName = typeof body.authorName === 'string' && body.authorName.trim() ? body.authorName.trim() : 'Autonomy Studio LMS Bot' + const authorEmail = typeof body.authorEmail === 'string' && body.authorEmail.trim() ? body.authorEmail.trim() : 'lms-bot@futuredrive.net' + await command('git', ['add', '-A', '--', ...files.map((file) => file.path)], target, 20_000) + await command('git', ['-c', `user.name=${authorName}`, '-c', `user.email=${authorEmail}`, 'commit', '-m', message], target, 30_000) + await command('git', ['push', '-u', 'origin', 'HEAD'], target, 120_000) + const commit = parseGitCommits(await command('git', ['log', '-1', '--pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s', '--date=iso-strict'], target, 10_000))[0] + if (commit && typeof body.submissionId === 'string' && body.submissionId.trim()) { + const metadataPath = resolve(target, studioGitMetadataFile) + const metadata: { submissions?: unknown[] } = await readFile(metadataPath, 'utf8').then((value) => JSON.parse(value) as { submissions?: unknown[] }).catch(() => ({})) + const submission = { id: body.submissionId.trim(), submittedAt: new Date().toISOString(), moduleName: module.name, commit } + await writeFile(metadataPath, `${JSON.stringify({ ...metadata, submissions: [submission, ...(metadata.submissions ?? [])].slice(0, 100) }, null, 2)}\n`, 'utf8') + } + return { ok: true, message: '제출되었습니다.', commit } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } + } + + private async gitRestore(body: Record) { + try { + const { target } = this.moduleRoot(body) + const path = normalizeRendererPath(this.moduleFromBody(body).name, body.filePath) + if (!path) throw new Error('파일 경로가 필요합니다.') + if (body.status === 'untracked') await unlink(resolve(target, path)).catch(async () => rm(resolve(target, path), { recursive: true, force: true })) + else await command('git', ['restore', '--staged', '--worktree', '--', path], target, 15_000) + return { ok: true, message: '변경사항을 되돌렸습니다.' } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } + } + + private async gitSubmissions(body: Record) { + const modules = Array.isArray(body.modules) ? body.modules : [] + const workspaceRoot = typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined + const submissions: unknown[] = [] + for (const item of modules) { + if (!item || typeof item !== 'object') continue + const module = item as { moduleName?: string; folderName?: string | null } + if (!module.moduleName || !validFolderName(module.folderName)) continue + const root = expandWorkspaceRoot(workspaceRoot) + const metadata: { submissions?: unknown[] } = await readFile(resolve(root, module.folderName, studioGitMetadataFile), 'utf8').then((value) => JSON.parse(value) as { submissions?: unknown[] }).catch(() => ({})) + submissions.push(...(metadata.submissions ?? [])) + } + return { ok: true, submissions: submissions.sort((left, right) => Date.parse(String((right as { submittedAt?: string }).submittedAt)) - Date.parse(String((left as { submittedAt?: string }).submittedAt))) } + } + + private async gitCommitDiff(body: Record) { + try { + const { target } = this.moduleRoot(body) + const hash = typeof body.commitHash === 'string' ? body.commitHash.trim() : '' + if (!/^[0-9a-f]{7,64}$/i.test(hash)) throw new Error('커밋 해시가 올바르지 않습니다.') + return { ok: true, diff: await command('git', ['show', '--format=', '--no-ext-diff', `${hash}^!`], target, 20_000) } + } catch (error) { + return { ok: false, diff: '', message: error instanceof Error ? error.message : String(error) } + } + } + + private terminalKey(body: Record) { + const id = typeof body.sessionId === 'string' ? body.sessionId : 'default' + if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(id)) throw new Error('터미널 세션 ID가 올바르지 않습니다.') + return id + } + + private appendTerminalOutput(terminal: TerminalSession, chunk: string) { + terminal.output = `${terminal.output}${chunk}`.slice(-256 * 1024) + terminal.cursor = Math.min(terminal.cursor, terminal.output.length) + for (const wake of terminal.readers) wake() + terminal.readers.clear() + } + + private async readTerminalOutput(terminal: TerminalSession, waitMs: unknown) { + const consume = () => { + const data = terminal.output.slice(terminal.cursor) + terminal.cursor = terminal.output.length + return data + } + const current = consume() + if (current || terminal.exited) return current + + const timeout = terminalSize(waitMs, 20_000, 0, 25_000) + if (!timeout) return '' + await new Promise((resolvePromise) => { + const wake = () => { + clearTimeout(timer) + terminal.readers.delete(wake) + resolvePromise() + } + const timer = setTimeout(wake, timeout) + terminal.readers.add(wake) + }) + return consume() + } + + private async startTerminal(body: Record) { + const key = this.terminalKey(body) + const existing = this.terminals.get(key) + const clientId = typeof body.clientId === 'string' && body.clientId ? body.clientId : null + if (existing && !existing.exited && existing.clientId === clientId) return { ok: true } + if (existing && !existing.exited) existing.process.kill() + const moduleName = typeof body.moduleName === 'string' && body.moduleName.trim() ? body.moduleName.trim() : undefined + let cwd = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined) + if (moduleName) { + const module = this.moduleFromBody({ module: { name: moduleName, folderName: body.folderName } }) + cwd = resolve(cwd, module.folderName) + if (!existsSync(cwd)) throw new Error('설치된 모듈을 찾을 수 없습니다.') + } + await mkdir(cwd, { recursive: true }) + const cols = terminalSize(body.cols, 80, 20, 300) + const rows = terminalSize(body.rows, 24, 5, 120) + const shellProcess = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, cwd, cols, rows)], { + cwd, + name: 'xterm-256color', + cols, + rows, + env: terminalEnvironment(cols, rows), + }) + const terminal: TerminalSession = { clientId, process: shellProcess, exited: false, output: '', cursor: 0, readers: new Set() } + shellProcess.onData((chunk) => this.appendTerminalOutput(terminal, chunk)) + shellProcess.onExit(({ exitCode }) => { + terminal.exited = true + this.appendTerminalOutput(terminal, `\r\n[terminal exited: ${exitCode}]\r\n`) + }) + this.terminals.set(key, terminal) + return { ok: true } + } + + private async changeTerminalDirectory(body: Record) { + const terminal = this.terminals.get(this.terminalKey(body)) + if (!terminal || terminal.exited) return { ok: false } + const module = this.moduleFromBody({ module: { name: body.moduleName, folderName: body.folderName } }) + const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined) + const cwd = resolve(root, module.folderName) + if (!existsSync(cwd)) throw new Error('설치된 모듈을 찾을 수 없습니다.') + terminal.process.write(`cd '${cwd.replace(/'/g, "'\\''")}'\n`) + return { ok: true } + } + + private writeTerminal(body: Record) { + const terminal = this.terminals.get(this.terminalKey(body)) + if (!terminal || terminal.exited) return { ok: false } + if (typeof body.data !== 'string' || Buffer.byteLength(body.data) > 64 * 1024) throw new Error('터미널 입력이 올바르지 않습니다.') + terminal.process.write(body.data) + return { ok: true } + } + + private resizeTerminal(body: Record) { + const terminal = this.terminals.get(this.terminalKey(body)) + if (!terminal || terminal.exited) return { ok: false } + terminal.process.resize(terminalSize(body.cols, 80, 20, 300), terminalSize(body.rows, 24, 5, 120)) + return { ok: true } + } + + private async readTerminal(body: Record) { + const terminal = this.terminals.get(this.terminalKey(body)) + if (!terminal) return { ok: true, data: '' } + return { ok: true, data: await this.readTerminalOutput(terminal, body.waitMs) } + } + + private stopTerminal(body: Record) { + const key = this.terminalKey(body) + const terminal = this.terminals.get(key) + if (terminal && !terminal.exited) terminal.process.kill() + if (terminal) { + for (const wake of terminal.readers) wake() + terminal.readers.clear() + } + this.terminals.delete(key) + return { ok: true } + } + + private remoteModule(body: Record) { + const target = this.remoteTarget(body) + const module = this.moduleFromBody(body) + const rootScript = `ROOT=${quoteShell(target.workspaceRoot ?? '')}\ncase "$ROOT" in ~/*) ROOT="$HOME/${'$'}{ROOT#~/}";; esac\nMODULE="$ROOT/${module.folderName}"\n` + return { target, module, rootScript } + } + + private remoteFilePath(body: Record, key: string) { + const remote = this.remoteModule(body) + const relativePath = normalizeRendererPath(remote.module.name, body[key]) + return { ...remote, relativePath, pathExpression: relativePath ? `"$MODULE/${relativePath}"` : '"$MODULE"' } + } + + private async readRemoteFiles(body: Record) { + const { target, module, rootScript } = this.remoteModule(body) + const script = `${rootScript} +[ -d "$MODULE" ] || exit 0 +find "$MODULE" -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 + [ -n "$REL" ] || continue + if [ "$KIND" = D ]; then printf 'D\\t%s\\n' "$(printf %s "$REL" | base64 -w0)"; continue; fi + CONTENT='QmluYXJ5IG9yIGxhcmdlIGZpbGUgcHJldmlldyBpcyBub3QgYXZhaWxhYmxlLg==' + case "${'$'}{REL##*/}" in *.c|*.cc|*.cpp|*.cxx|*.h|*.hpp|*.hh|*.py|*.xml|*.yaml|*.yml|*.json|*.txt|*.md|*.cmake|*.launch|*.sh|*.bash|*.toml|*.ini|*.cfg|*.rviz) [ "${'$'}{SIZE:-0}" -le 1048576 ] && CONTENT="$(base64 -w0 "$MODULE/$REL" 2>/dev/null)";; esac + printf 'F\\t%s\\t%s\\n' "$(printf %s "$REL" | base64 -w0)" "$CONTENT" +done` + const output = await this.ssh(target, script, 30_000) + const files: Record = {} + const folders: string[] = [] + for (const line of output.split('\n')) { + const [kind, encodedPath = '', encodedContent = ''] = line.split('\t') + if (!encodedPath) continue + const path = Buffer.from(encodedPath, 'base64').toString('utf8') + if (!path) continue + if (kind === 'D') folders.push(`modules/${module.name}/${path}`) + if (kind === 'F') files[`modules/${module.name}/${path}`] = Buffer.from(encodedContent, 'base64').toString('utf8') + } + return { root: module.folderName, absoluteRoot: null, files, folders } + } + + private async remoteFileMutation(body: Record, action: 'create' | 'write' | 'copy' | 'rename' | 'delete') { + const from = action === 'copy' || action === 'rename' ? this.remoteFilePath(body, 'fromPath') : null + const target = from ?? this.remoteFilePath(body, 'path') + const destination = action === 'copy' || action === 'rename' ? this.remoteFilePath(body, 'toPath') : null + let script = target.rootScript + if (action === 'create') { + if (body.kind === 'folder') script += `mkdir ${target.pathExpression}` + else if (body.kind === 'file') script += `mkdir -p "$(dirname ${target.pathExpression})"\ntouch ${target.pathExpression}` + else throw new Error('생성 유형이 올바르지 않습니다.') + } else if (action === 'write') { + if (typeof body.content !== 'string' || Buffer.byteLength(body.content) > 1024 * 1024) throw new Error('파일 내용이 올바르지 않습니다.') + script += `mkdir -p "$(dirname ${target.pathExpression})"\nprintf %s ${quoteShell(Buffer.from(body.content).toString('base64'))} | base64 -d > ${target.pathExpression}` + } else if (action === 'delete') { + if (!target.relativePath) throw new Error('모듈 루트는 삭제할 수 없습니다.') + script += `rm -rf ${target.pathExpression}` + } else if (destination && from) { + script += `mkdir -p "$(dirname ${destination.pathExpression})"\n${action === 'rename' || body.mode === 'cut' ? 'mv' : 'cp -a'} ${from.pathExpression} ${destination.pathExpression}` + } + await this.ssh(target.target, script, 30_000) + return { ok: true } + } + + private async remoteGitStatus(body: Record) { + try { + const { target, rootScript } = this.remoteModule(body) + const statusOutput = await this.ssh(target, [rootScript, 'git -C "$MODULE" status --porcelain=v1 -b -uall 2>/dev/null'].join('\n'), 15_000) + const { branch, ahead, behind } = parseGitBranch(statusOutput) + const files = parseGitStatus(statusOutput) + const selectedFilePath = typeof body.selectedFilePath === 'string' && files.some((file) => file.path === body.selectedFilePath) ? body.selectedFilePath : files[0]?.path ?? null + const path = selectedFilePath && !selectedFilePath.split('/').some((part) => part === '..') ? selectedFilePath : '' + const details = await this.ssh(target, [rootScript, `printf 'DIFF\\t'; git -C "$MODULE" diff --no-ext-diff -- ${quoteShell(path)} 2>/dev/null | base64 -w0`, `printf '\\nCOMMITS\\t'; git -C "$MODULE" log --max-count=20 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict 2>/dev/null | base64 -w0`].join('\n'), 20_000) + const values = new Map(details.split('\n').map((line) => line.split('\t', 2) as [string, string])) + return { ok: true, branch, ahead, behind, files, selectedFilePath, diff: Buffer.from(values.get('DIFF') ?? '', 'base64').toString('utf8'), commits: parseGitCommits(Buffer.from(values.get('COMMITS') ?? '', 'base64').toString('utf8')) } + } catch (error) { + return { ok: false, branch: null, ahead: 0, behind: 0, files: [], selectedFilePath: null, diff: '', commits: [], message: error instanceof Error ? error.message : String(error) } + } + } + + private async remoteGitSubmit(body: Record) { + try { + const { target, module, rootScript } = this.remoteModule(body) + const statusOutput = await this.ssh(target, [rootScript, 'git -C "$MODULE" status --porcelain=v1 -uall 2>/dev/null'].join('\n'), 15_000) + const files = parseGitStatus(statusOutput) + if (!files.length) return { ok: false, message: '제출할 변경사항이 없습니다.' } + const message = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : `${module.name} 제출` + const authorName = typeof body.authorName === 'string' && body.authorName.trim() ? body.authorName.trim() : 'Autonomy Studio LMS Bot' + const authorEmail = typeof body.authorEmail === 'string' && body.authorEmail.trim() ? body.authorEmail.trim() : 'lms-bot@futuredrive.net' + const paths = files.map((file) => quoteShell(file.path)).join(' ') + const script = [rootScript, 'set -e', `git -C "$MODULE" add -A -- ${paths}`, `git -C "$MODULE" -c user.name=${quoteShell(authorName)} -c user.email=${quoteShell(authorEmail)} commit -m ${quoteShell(message)}`, 'git -C "$MODULE" push -u origin HEAD', 'git -C "$MODULE" log -1 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict'].join('\n') + return { ok: true, message: '제출되었습니다.', commit: parseGitCommits(await this.ssh(target, script, 150_000))[0] } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } + } + + private async remoteGitRestore(body: Record) { + try { + const { target, rootScript } = this.remoteModule(body) + const path = normalizeRendererPath(this.moduleFromBody(body).name, body.filePath) + if (!path) throw new Error('파일 경로가 필요합니다.') + const commandLine = body.status === 'untracked' ? `rm -rf "$MODULE/${path}"` : `git -C "$MODULE" restore --staged --worktree -- ${quoteShell(path)}` + await this.ssh(target, [rootScript, commandLine].join('\n'), 20_000) + return { ok: true, message: '변경사항을 되돌렸습니다.' } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } + } + + private async remoteGitCommitDiff(body: Record) { + try { + const { target, rootScript } = this.remoteModule(body) + const hash = typeof body.commitHash === 'string' ? body.commitHash.trim() : '' + if (!/^[0-9a-f]{7,64}$/i.test(hash)) throw new Error('커밋 해시가 올바르지 않습니다.') + return { ok: true, diff: await this.ssh(target, [rootScript, `git -C "$MODULE" show --format= --no-ext-diff ${quoteShell(`${hash}^!`)}`].join('\n'), 30_000) } + } catch (error) { + return { ok: false, diff: '', message: error instanceof Error ? error.message : String(error) } + } + } + + private remoteTerminalKey(body: Record) { + const target = this.remoteTarget(body) + return `${target.host}:${target.port}:${target.user}:${this.terminalKey(body)}` + } + + private async startRemoteTerminal(body: Record) { + const key = this.remoteTerminalKey(body) + const existing = this.terminals.get(key) + const clientId = typeof body.clientId === 'string' && body.clientId ? body.clientId : null + if (existing && !existing.exited && existing.clientId === clientId) return { ok: true } + if (existing && !existing.exited) existing.process.kill() + const target = this.remoteTarget(body) + const moduleName = typeof body.moduleName === 'string' && body.moduleName.trim() ? body.moduleName.trim() : undefined + let remotePath = target.workspaceRoot ?? '' + if (moduleName) { + const module = this.moduleFromBody({ module: { name: moduleName, folderName: body.folderName } }) + remotePath = `${remotePath.replace(/\/+$/, '')}/${module.folderName}` + } + const cols = terminalSize(body.cols, 80, 20, 300) + const rows = terminalSize(body.rows, 24, 5, 120) + const shell = buildRemoteWorkspaceShellCommand(moduleName, remotePath, cols, rows) + const shellProcess = pty.spawn('ssh', ['-tt', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=8', '-p', String(target.port ?? 22), `${target.user}@${target.host}`, shell], { + name: 'xterm-256color', + cols, + rows, + env: terminalEnvironment(cols, rows), + }) + const terminal: TerminalSession = { clientId, process: shellProcess, exited: false, output: '', cursor: 0, readers: new Set() } + shellProcess.onData((chunk) => this.appendTerminalOutput(terminal, chunk)) + shellProcess.onExit(({ exitCode }) => { + terminal.exited = true + this.appendTerminalOutput(terminal, `\r\n[terminal exited: ${exitCode}]\r\n`) + }) + this.terminals.set(key, terminal) + return { ok: true } + } + + private async changeRemoteTerminalDirectory(body: Record) { + const key = this.remoteTerminalKey(body) + const terminal = this.terminals.get(key) + if (!terminal || terminal.exited) return { ok: false } + const target = this.remoteTarget(body) + const module = this.moduleFromBody({ module: { name: body.moduleName, folderName: body.folderName } }) + const path = `${(target.workspaceRoot ?? '').replace(/\/+$/, '')}/${module.folderName}` + terminal.process.write(`cd ${quoteShell(path)}\n`) + return { ok: true } + } + + private writeRemoteTerminal(body: Record) { + const terminal = this.terminals.get(this.remoteTerminalKey(body)) + if (!terminal || terminal.exited) return { ok: false } + if (typeof body.data !== 'string' || Buffer.byteLength(body.data) > 64 * 1024) throw new Error('터미널 입력이 올바르지 않습니다.') + terminal.process.write(body.data) + return { ok: true } + } + + private resizeRemoteTerminal(body: Record) { + const terminal = this.terminals.get(this.remoteTerminalKey(body)) + if (!terminal || terminal.exited) return { ok: false } + terminal.process.resize(terminalSize(body.cols, 80, 20, 300), terminalSize(body.rows, 24, 5, 120)) + return { ok: true } + } + + private async readRemoteTerminal(body: Record) { + const terminal = this.terminals.get(this.remoteTerminalKey(body)) + if (!terminal) return { ok: true, data: '' } + return { ok: true, data: await this.readTerminalOutput(terminal, body.waitMs) } + } + + private stopRemoteTerminal(body: Record) { + const key = this.remoteTerminalKey(body) + const terminal = this.terminals.get(key) + if (terminal && !terminal.exited) terminal.process.kill() + if (terminal) { + for (const wake of terminal.readers) wake() + terminal.readers.clear() + } + this.terminals.delete(key) + return { ok: true } + } + + private async listRemoteModules(body: Record) { + const target = this.remoteTarget(body) + const requested = Array.isArray(body.modules) ? body.modules.slice(0, 100) : [] + const statuses = await this.ssh(target, 'docker ps -a --format "{{.Names}}\\t{{.Status}}" 2>/dev/null || true', 15_000) + const docker = new Map(statuses.split('\n').filter(Boolean).map((line) => { + const [name, status = ''] = line.split('\t') + return [name, { running: /^Up\b/i.test(status), status }] + })) + const root = target.workspaceRoot ?? '' + const modules = await Promise.all(requested.map(async (raw, index) => { + const module = raw as CourseModule + if (!module?.name || !validFolderName(module.folderName)) return null + const installed = (await this.ssh(target, `[ -d ${quoteShell(`${root.replace(/\/+$/, '')}/${module.folderName}`)} ] && printf yes || printf no`, 10_000)).trim() === 'yes' + const container = containerName(module.folderName) + const status = container ? docker.get(container) : undefined + return { ...module, sortOrder: Number(module.sortOrder) || index + 1, workspacePath: installed ? `${root}/${module.folderName}` : null, state: status?.running ? 'running' : installed ? 'stopped' : 'uninstalled', cpu: null, memory: null, runtime: status?.running ? this.formatRuntime(status.status) : '-' } + })) + return { workspaceRoot: root, modules: modules.filter(Boolean) } + } + + private async installRemoteModule(body: Record) { + const { target, module, rootScript } = this.remoteModule(body) + if (!validRepositoryUrl(module.templateRepositoryUrl)) throw new Error('템플릿 저장소 주소가 필요합니다.') + const studentUrl = validRepositoryUrl(module.studentRepositoryUrl) ? module.studentRepositoryUrl.trim() : '' + const script = [rootScript, 'mkdir -p "$ROOT"', '[ -d "$MODULE/.git" ] && exit 0', `git clone ${quoteShell(studentUrl || module.templateRepositoryUrl.trim())} "$MODULE"`].join('\n') + await this.ssh(target, script, 120_000) + return { ok: true, message: '모듈을 설치했습니다.', workspacePath: `${target.workspaceRoot}/${module.folderName}` } + } + + private async stopRemoteModule(body: Record) { + const { target, module } = this.remoteModule(body) + const container = containerName(module.folderName) + if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.') + const buildContainer = `${container}-build` + const buildStatus = await this.ssh(target, `docker ps -a --filter ${quoteShell(`name=^/${buildContainer}$`)} --format '{{.Status}}' 2>/dev/null || true`, 15_000) + if (buildStatus.trim()) await this.ssh(target, `docker rm -f ${quoteShell(buildContainer)} >/dev/null 2>&1 || true`, 30_000) + const status = await this.ssh(target, `docker ps -a --filter ${quoteShell(`name=^/${container}$`)} --format '{{.Status}}' 2>/dev/null || true`, 15_000) + if (/^Up\b/i.test(status.trim())) await this.ssh(target, `docker stop ${quoteShell(container)} >/dev/null 2>&1 || true`, 30_000) + return { ok: true, message: buildStatus.trim() && !status.trim() ? '빌드를 중지했습니다.' : '중지했습니다.' } + } + + private async startRemoteModule(body: Record) { + const { target, module, rootScript } = this.remoteModule(body) + const container = containerName(module.folderName) + if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.') + const rosDomainId = String(body.rosDomainId ?? '0').replace(/[^0-9]/g, '') || '0' + const requestedParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : null + const gpuEnabled = body.gpuEnabled === true + const buildOnly = body.buildOnly === true + const runtimeImages = (Array.isArray(body.runtimeImages) ? body.runtimeImages : []) + .filter((value): value is RuntimeImageArtifact => Boolean(value && typeof value === 'object')) + .map((value) => ({ + image: value.image?.trim() ?? '', + url: value.url?.trim() ?? '', + fileName: value.fileName?.trim() ?? '', + checksumSha256: value.checksumSha256?.trim() ?? '', + required: Boolean(value.required), + })) + + const currentStatus = await this.ssh(target, `docker ps -a --filter ${quoteShell(`name=^/${container}$`)} --format '{{.Status}}' 2>/dev/null || true`, 15_000) + if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } + + await this.ssh(target, [ + rootScript, + '[ -d "$MODULE" ] || { echo "설치된 모듈을 찾을 수 없습니다." >&2; exit 1; }', + 'RUNTIME_ROOT="$HOME/autonomy-studio/runtime"', + 'DOCKER_ROOT="$RUNTIME_ROOT/docker"', + 'mkdir -p "$DOCKER_ROOT" "$RUNTIME_ROOT/config"', + `printf %s ${quoteShell(Buffer.from(buildAutowareDockerRunScript()).toString('base64'))} | base64 -d > "$DOCKER_ROOT/docker_run_autoware-universe:humble-20240301-cuda.sh"`, + 'cp "$DOCKER_ROOT/docker_run_autoware-universe:humble-20240301-cuda.sh" "$DOCKER_ROOT/docker_run_autoware-universe_humble-20240301-cuda.sh"', + 'chmod +x "$DOCKER_ROOT/docker_run_autoware-universe:humble-20240301-cuda.sh" "$DOCKER_ROOT/docker_run_autoware-universe_humble-20240301-cuda.sh"', + `printf %s ${quoteShell(Buffer.from(buildCycloneDdsConfig()).toString('base64'))} | base64 -d > "$DOCKER_ROOT/cyclonedds_config.xml"`, + `RUNTIME_IMAGE_JSON="$(printf %s ${quoteShell(Buffer.from(JSON.stringify(runtimeImages)).toString('base64'))} | base64 -d)"`, + 'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', + 'mkdir -p "$ARTIFACT_CACHE"', + "python3 - \"$RUNTIME_IMAGE_JSON\" \"$ARTIFACT_CACHE\" <<'PY'", + 'import hashlib, json, os, shlex, subprocess, sys', + 'for item in json.loads(sys.argv[1] or "[]"):', + ' image = (item.get("image") or "").strip()', + ' if not image or subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: continue', + ' url = (item.get("url") or "").strip()', + ' if not url:', + ' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")', + ' continue', + ' name = (item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar").replace("/", "_")', + ' path = os.path.join(sys.argv[2], name)', + ' if not os.path.exists(path): subprocess.run(["curl", "-fL", url, "-o", path], check=True)', + ' expected = (item.get("checksumSha256") or "").strip().lower()', + ' if expected:', + ' with open(path, "rb") as stream: actual = hashlib.sha256(stream.read()).hexdigest().lower()', + ' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")', + ' if path.lower().endswith((".tar.zst", ".tzst")): subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)', + ' else: subprocess.run(["docker", "load", "-i", path], check=True)', + 'PY', + ...(buildOnly ? [] : [`docker rm -f ${quoteShell(container)} >/dev/null 2>&1 || true`]), + `PARAMS=${quoteShell(requestedParams ?? '')}`, + requestedParams === null ? 'if [ -z "$PARAMS" ] && [ -f "$MODULE/run.sh" ]; then PARAMS=$(grep -E "^ROS_PARAM=" "$MODULE/run.sh" | head -n 1 || true); PARAMS=${PARAMS#ROS_PARAM=}; PARAMS=${PARAMS#\'}; PARAMS=${PARAMS%\'}; PARAMS=${PARAMS#\"}; PARAMS=${PARAMS%\"}; fi' : '', + ...(buildOnly + ? [ + `BUILD_CONTAINER=${quoteShell(`${container}-build`)}`, + 'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true', + `docker run --rm --name "$BUILD_CONTAINER" --volume="$MODULE:/autoware:rw" -u root --net=host -e ROS_DOMAIN_ID=${quoteShell(rosDomainId)} -v /etc/localtime:/etc/localtime:ro --entrypoint /bin/bash ${quoteShell(AUTOWARE_IMAGE)} -lc 'cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install'`, + ] + : [ + 'cd "$RUNTIME_ROOT"', + `SHARD_DIR="$ROOT" bash "$DOCKER_ROOT/docker_run_autoware-universe:humble-20240301-cuda.sh" ${quoteShell(container)} ${quoteShell(module.folderName)} localhost localhost ${quoteShell(AUTOWARE_IMAGE)} "$PARAMS" ${quoteShell(rosDomainId)} ${gpuEnabled ? 'true' : 'false'} false`, + ]), + ].filter(Boolean).join('\n'), 900_000) + return { ok: true, message: buildOnly ? '빌드가 완료되었습니다.' : '실행 요청을 보냈습니다.' } + } + + private async buildRemoteModule(body: Record) { + return this.startRemoteModule({ ...body, buildOnly: true }) + } + + private async remoteModuleLogs(body: Record) { + const { target, module } = this.remoteModule(body) + const container = containerName(module.folderName) + if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.') + const logs = await this.ssh(target, `docker logs --timestamps --tail 300 ${quoteShell(container)} 2>&1 || true`, 15_000) + return { ok: true, logs } + } +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7ed0895..6290485 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,6 +4,7 @@ import Login from './pages/Login' import Dashboard from './pages/Dashboard' import AppWindowShell from './pages/AppWindowShell' import type { AuthSession } from './pages/LoginForm' +import { refreshLauncherSession } from './auth/sessionRefresh' const AUTH_SESSION_KEY = 'atnmy.auth.session' const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '') @@ -90,11 +91,7 @@ function App(): React.JSX.Element { const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000) const timer = window.setTimeout(() => { - fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, { - method: 'POST', - credentials: 'include', - }) - .then((response) => response.ok ? response.json() as Promise : null) + refreshLauncherSession() .then((session) => { if (session?.accessToken) { handleSessionUpdate(session) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b933126..0cfc2ed 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -437,7 +437,7 @@ body, padding: 9px 12px; background: transparent; border: 0; - color: #fca5a5; + color: var(--text-secondary); font-family: 'NanumGothic', sans-serif; font-size: 12px; font-weight: 700; @@ -446,6 +446,14 @@ body, } .launch-action-dropdown button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.07); +} + +.launch-action-dropdown .launch-action-danger { + color: #fca5a5; +} + +.launch-action-dropdown .launch-action-danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.12); } @@ -719,6 +727,10 @@ body, line-height: 18px; } +.login-version-wrap .launcher-update-text { + transform: translateY(-1px); +} + .app-version-wrap { position: absolute; right: 24px; diff --git a/src/renderer/src/auth/sessionRefresh.ts b/src/renderer/src/auth/sessionRefresh.ts new file mode 100644 index 0000000..b323fed --- /dev/null +++ b/src/renderer/src/auth/sessionRefresh.ts @@ -0,0 +1,21 @@ +import type { AuthSession } from '../pages/LoginForm' + +const launcherApiBaseUrl = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '') + +let pendingRefresh: Promise | null = null + +export function refreshLauncherSession(): Promise { + if (pendingRefresh) return pendingRefresh + + pendingRefresh = fetch(`${launcherApiBaseUrl}/api/auth/refresh`, { + method: 'POST', + credentials: 'include', + }) + .then((response) => response.ok ? response.json() as Promise : null) + .catch(() => null) + .finally(() => { + pendingRefresh = null + }) + + return pendingRefresh +} diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 516ac91..3e63c63 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -5,6 +5,7 @@ import Tooltip from '../components/Tooltip' import AppView from './AppView' import type { LauncherApp } from '../config/apps' import type { AuthSession } from './LoginForm' +import { refreshLauncherSession } from '../auth/sessionRefresh' const version = import.meta.env.VITE_APP_VERSION @@ -17,6 +18,7 @@ interface DashboardProps { const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '') const MANUAL_BASE_URL = (import.meta.env.VITE_MANUAL_BASE_URL || 'https://portal.autonomystudio.net/manual').replace(/\/+$/, '') +const LMS_WEB_BASE_URL = `${(import.meta.env.VITE_LMS_WEB_BASE_URL || 'http://localhost:5177/lms-web/').replace(/\/+$/, '')}/` const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures' const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp' const IS_DEV = import.meta.env.DEV @@ -186,14 +188,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: : null const activeAppDisplayName = appStatus?.displayName || appConfig?.displayName || activeApp const refreshSession = useCallback(async (): Promise => { - const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, { - method: 'POST', - credentials: 'include', - }) - - if (!response.ok) return null - - const session = await response.json() as AuthSession + const session = await refreshLauncherSession() + if (!session) return null onSessionUpdate(session) return session }, [onSessionUpdate]) @@ -722,13 +718,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: let response = await requestHandoff(authSession.accessToken) if (response.status === 401) { - const refreshResponse = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, { - method: 'POST', - credentials: 'include', - }) - - if (refreshResponse.ok) { - const nextSession = await refreshResponse.json() as AuthSession + const nextSession = await refreshLauncherSession() + if (nextSession) { onSessionUpdate(nextSession) response = await requestHandoff(nextSession.accessToken) } else { @@ -786,6 +777,32 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: } }, [activeApp, appConfig, canLaunch, createHandoffToken, launchingApp]) + const handleWebLaunch = useCallback(async () => { + if (activeApp.trim().toLowerCase() !== 'lms' || launchingApp) return + + setAppActionMenuOpen(false) + setLaunchingApp(activeApp) + try { + const handoffToken = await createHandoffToken('LMS') + if (!handoffToken) return + + const launchUrl = new URL(LMS_WEB_BASE_URL) + launchUrl.hash = new URLSearchParams({ + handoffToken, + audienceApp: 'LMS', + }).toString() + await window.electron.ipcRenderer.invoke('launch-web-app', { + appName: activeApp, + url: launchUrl.toString(), + }) + window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp) + } catch (error) { + alert(`웹 실행에 실패했습니다. (${error instanceof Error ? error.message : '알 수 없는 오류'})`) + } finally { + setLaunchingApp(null) + } + }, [activeApp, createHandoffToken, launchingApp]) + const handleFocusApp = useCallback(() => { window.electron.ipcRenderer.send('focus-app-window', activeApp) }, [activeApp]) @@ -968,7 +985,17 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: {appActionMenuOpen && (
- + )} +