3128 lines
136 KiB
TypeScript
3128 lines
136 KiB
TypeScript
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<string, ChildProcessWithoutNullStreams>()
|
|
const terminalStopTimers = new Map<string, NodeJS.Timeout>()
|
|
const intentionalTerminalStops = new Set<string>()
|
|
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<string, string> = {
|
|
'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<string, string>
|
|
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<string, string>
|
|
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<string, string>
|
|
}
|
|
}
|
|
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
const dockerStats = await readDockerStats().catch(() => new Map<string, { cpu: number; memory: number }>())
|
|
const modules = await Promise.all(moduleInputs
|
|
.map(async (module): Promise<CachedModuleInfo> => {
|
|
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<string, { running: boolean; exists: boolean; status: string }>()
|
|
const dockerStats = new Map<string, { cpu: number; memory: number }>()
|
|
const installedModules = new Map<string, { path: string; parameters: string[] }>()
|
|
|
|
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<string> {
|
|
return new Promise<string>((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<void> {
|
|
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<Pick<RemoteWorkspaceTarget, 'host' | 'user' | 'port'>> & { 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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<StudioGitMetadata> {
|
|
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<void> {
|
|
await writeFile(resolve(moduleRoot, studioGitMetadataFile), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
async function readStudioGitBaseCommit(moduleRoot: string): Promise<string | null> {
|
|
const metadata = await readStudioGitMetadata(moduleRoot)
|
|
return metadata.baseCommit?.trim() || null
|
|
}
|
|
|
|
async function writeStudioGitBaseCommit(moduleRoot: string, baseCommit: string): Promise<void> {
|
|
const metadata = await readStudioGitMetadata(moduleRoot)
|
|
await writeStudioGitMetadata(moduleRoot, { ...metadata, baseCommit })
|
|
}
|
|
|
|
async function appendStudioGitSubmission(moduleRoot: string, submission: StudioGitSubmission): Promise<void> {
|
|
const metadata = await readStudioGitMetadata(moduleRoot)
|
|
await writeStudioGitMetadata(moduleRoot, {
|
|
...metadata,
|
|
submissions: [submission, ...(metadata.submissions ?? [])].slice(0, 100)
|
|
})
|
|
}
|
|
|
|
async function inferStudioGitBaseCommit(moduleRoot: string): Promise<string | null> {
|
|
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<string> {
|
|
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<typeof normalizeRemoteTarget>; 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<string> {
|
|
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<string> {
|
|
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<GitStatusInfo> {
|
|
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<GitStatusInfo> {
|
|
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<string, string>()
|
|
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<StudioGitMetadata> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Map<string, { running: boolean; exists: boolean; status: string }>> {
|
|
const statuses = new Map<string, { running: boolean; exists: boolean; status: string }>()
|
|
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<Map<string, { cpu: number; memory: number }>> {
|
|
const stats = new Map<string, { cpu: number; memory: number }>()
|
|
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<string | null> {
|
|
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<void> {
|
|
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, string>): 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<string, string>): Promise<void> {
|
|
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<void> {
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
|
|
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<VisualizationBridgeStatus> {
|
|
try {
|
|
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>())
|
|
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<void> {
|
|
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<string, unknown> = {},
|
|
): Promise<VisualizationBridgeStatus> {
|
|
const agentUrl = profile.agent?.url?.trim().replace(/\/+$/, '')
|
|
if (!agentUrl) throw new Error('원격 제어 Agent URL이 설정되지 않았습니다.')
|
|
|
|
const headers: Record<string, string> = { '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<VisualizationBridgeStatus> {
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
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<string>()
|
|
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<VisualizationBridgeStatus> {
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
const targetNames = new Set<string>((['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 [
|
|
'<?xml version="1.0" encoding="UTF-8" ?>',
|
|
'<CycloneDDS xmlns="https://cdds.io/config" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://cdds.io/config https://raw.githubusercontent.com/eclipse-cyclonedds/cyclonedds/master/etc/cyclonedds.xsd">',
|
|
' <Domain Id="any">',
|
|
' <General>',
|
|
' <Interfaces>',
|
|
' <NetworkInterface autodetermine="true" priority="default" multicast="default" />',
|
|
' </Interfaces>',
|
|
' <AllowMulticast>default</AllowMulticast>',
|
|
' <MaxMessageSize>65500B</MaxMessageSize>',
|
|
' </General>',
|
|
' <Discovery>',
|
|
' <ParticipantIndex>auto</ParticipantIndex>',
|
|
' <MaxAutoParticipantIndex>120</MaxAutoParticipantIndex>',
|
|
' </Discovery>',
|
|
' <Internal>',
|
|
' <Watermarks>',
|
|
' <WhcHigh>500kB</WhcHigh>',
|
|
' </Watermarks>',
|
|
' </Internal>',
|
|
' </Domain>',
|
|
'</CycloneDDS>',
|
|
''
|
|
].join('\n')
|
|
}
|
|
|
|
async function ensureStudioRuntimeAssets(): Promise<void> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
const containerKeys = new Set<string>()
|
|
|
|
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<string, { running: boolean; exists: boolean; status: string }>())
|
|
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<CachedModuleFiles> {
|
|
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
|
|
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
|
|
|
|
const files: Record<string, string> = {}
|
|
const folders: string[] = []
|
|
const visit = async (currentPath: string, depth: number): Promise<void> => {
|
|
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<CachedModuleFiles> {
|
|
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<string, string> = {}
|
|
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<CachedModuleFiles> {
|
|
const files: Record<string, string> = {}
|
|
const folders: string[] = []
|
|
const visit = async (currentPath: string, depth: number): Promise<void> => {
|
|
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<string> {
|
|
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<typeof normalizeRemoteTarget>; 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<string> {
|
|
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()
|
|
})
|