This commit is contained in:
chyoo
2026-06-30 17:07:53 +09:00
parent 87554e27a1
commit d05ca7582c
37 changed files with 3839 additions and 2282 deletions
+324 -23
View File
@@ -1,11 +1,14 @@
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 { createWriteStream, 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'
import { createHash } from 'crypto'
import http from 'http'
import https from 'https'
let mainWindow: BrowserWindow | null = null
let splashWindow: BrowserWindow | null = null
@@ -13,6 +16,7 @@ const terminalProcesses = new Map<string, ChildProcessWithoutNullStreams>()
const terminalStopTimers = new Map<string, NodeJS.Timeout>()
const intentionalTerminalStops = new Set<string>()
const moduleCacheRoot = resolve(homedir(), '.caches')
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda'
const autowareRosBridgeImage = 'autoware-ros-bridge'
@@ -83,6 +87,8 @@ type CachedModuleInfo = {
sourcePath: string
workspacePath: string | null
templateRepositoryUrl?: string | null
sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
parameters: string[]
cpu: number | null
memory: number | null
@@ -95,6 +101,30 @@ type CourseModuleInput = {
name: string
folderName: string
templateRepositoryUrl?: string | null
sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
}
type ModulePrebuiltArtifact = {
type?: string | null
url?: string | null
fileName?: string | null
checksumSha256?: string | null
extractPath?: string | null
required?: boolean | null
}
type RuntimeImageArtifact = {
image?: string | null
url?: string | null
fileName?: string | null
checksumSha256?: string | null
required?: boolean | null
}
type ModuleInstallOptions = {
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
runtimeImages?: RuntimeImageArtifact[] | null
}
type RemoteWorkspaceTarget = {
@@ -144,6 +174,7 @@ type EnvironmentProfileConfig = {
logFile?: string
}>
}
runtimeImages?: RuntimeImageArtifact[] | null
bridge?: {
name?: string
image?: string
@@ -337,6 +368,8 @@ async function collectAvailableModules(
sourcePath,
workspacePath,
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
sourceType: module.sourceType ?? null,
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
parameters: splitRosParams(rosParams),
cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null,
memory: dockerStatus?.running ? dockerStat?.memory ?? null : null,
@@ -519,6 +552,120 @@ async function runCommand(command: string, args: string[], cwd?: string, env?: N
await runCommandOutput(command, args, cwd, env, timeoutMs)
}
function safeArtifactFileName(url: string, fileName?: string | null): string {
const rawName = fileName?.trim() || basename(new URL(url).pathname) || 'artifact.tar'
return rawName.replace(/[^a-zA-Z0-9._-]/g, '_')
}
function artifactCachePath(url: string, fileName?: string | null): string {
return resolve(artifactCacheRoot, safeArtifactFileName(url, fileName))
}
async function downloadArtifact(url: string, targetPath: string, redirects = 0): Promise<void> {
if (redirects > 5) throw new Error('artifact redirect limit exceeded')
await mkdir(dirname(targetPath), { recursive: true })
await new Promise<void>((resolvePromise, rejectPromise) => {
const parsed = new URL(url)
const client = parsed.protocol === 'http:' ? http : https
const request = client.get(parsed, (response) => {
const statusCode = response.statusCode ?? 0
const location = response.headers.location
if (statusCode >= 300 && statusCode < 400 && location) {
response.resume()
const nextUrl = new URL(location, parsed).toString()
downloadArtifact(nextUrl, targetPath, redirects + 1).then(resolvePromise, rejectPromise)
return
}
if (statusCode < 200 || statusCode >= 300) {
response.resume()
rejectPromise(new Error(`artifact download failed: HTTP ${statusCode}`))
return
}
const file = createWriteStream(targetPath)
response.pipe(file)
file.on('finish', () => file.close(() => resolvePromise()))
file.on('error', rejectPromise)
})
request.on('error', rejectPromise)
})
}
async function ensureArtifactFile(url?: string | null, fileName?: string | null, checksumSha256?: string | null): Promise<string | null> {
const artifactUrl = url?.trim()
if (!artifactUrl) return null
const targetPath = artifactCachePath(artifactUrl, fileName)
if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath)
if (checksumSha256?.trim()) {
const actual = createHash('sha256').update(await readFile(targetPath)).digest('hex')
if (actual.toLowerCase() !== checksumSha256.trim().toLowerCase()) {
await rm(targetPath, { force: true }).catch(() => undefined)
throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`)
}
}
return targetPath
}
function tarArgsFor(filePath: string, mode: 'list' | 'extract'): string[] {
const lower = filePath.toLowerCase()
if (lower.endsWith('.tar.zst') || lower.endsWith('.tzst')) return mode === 'list' ? ['--zstd', '-tf'] : ['--zstd', '-xf']
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return mode === 'list' ? ['-tzf'] : ['-xzf']
return mode === 'list' ? ['-tf'] : ['-xf']
}
function ensureSafeTarEntry(entry: string): void {
const normalized = entry.trim()
if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized === '..' || normalized.startsWith('..\\')) {
throw new Error(`unsafe artifact path: ${entry}`)
}
}
async function extractArtifact(filePath: string, targetRoot: string, extractPath?: string | null): Promise<void> {
const destination = resolve(targetRoot, extractPath?.trim() || '.')
if (destination !== targetRoot && !destination.startsWith(`${targetRoot}/`)) throw new Error('artifact extract path is outside module root')
const entries = await runCommandOutput('tar', [...tarArgsFor(filePath, 'list'), filePath], undefined, undefined, 120000)
entries.split('\n').filter(Boolean).forEach(ensureSafeTarEntry)
await mkdir(destination, { recursive: true })
await runCommand('tar', [...tarArgsFor(filePath, 'extract'), filePath, '-C', destination], undefined, undefined, 900000)
}
async function applyModulePrebuiltArtifacts(targetRoot: string, artifacts?: ModulePrebuiltArtifact[] | null): Promise<void> {
const normalizedArtifacts = (artifacts ?? []).filter((artifact) => artifact?.url?.trim())
for (const artifact of normalizedArtifacts) {
const filePath = await ensureArtifactFile(artifact.url, artifact.fileName, artifact.checksumSha256)
if (!filePath) {
if (artifact.required) throw new Error('required module artifact url is missing')
continue
}
await extractArtifact(filePath, targetRoot, artifact.extractPath)
}
}
async function prepareRuntimeImages(images?: RuntimeImageArtifact[] | null): Promise<void> {
const normalizedImages = (images ?? []).filter((image) => image?.image?.trim())
for (const image of normalizedImages) {
const imageName = image.image?.trim()
if (!imageName) continue
const exists = await runCommand('docker', ['image', 'inspect', imageName], undefined, undefined, 10000)
.then(() => true)
.catch(() => false)
if (exists) continue
const filePath = await ensureArtifactFile(image.url, image.fileName, image.checksumSha256)
if (!filePath) {
if (image.required) throw new Error(`runtime image artifact url is missing: ${imageName}`)
continue
}
if (filePath.toLowerCase().endsWith('.tar.zst') || filePath.toLowerCase().endsWith('.tzst')) {
await runCommand('bash', ['-lc', 'zstd -dc "$1" | docker load', 'autonomy-studio-runtime-image', filePath], undefined, undefined, 900000)
} else {
await runCommand('docker', ['load', '-i', filePath], undefined, undefined, 900000)
}
}
}
function defaultSshIdentityFile(): string {
return join(homedir(), '.autonomy-studio', 'ssh', 'id_ed25519')
}
@@ -1301,11 +1448,55 @@ function visualizationBridgeContainerName(stack: VisualizationStack): string {
}
function visualizationBridgeImageName(stack: VisualizationStack): string {
if (stack === 'autoware') return autowareRosBridgeImage
if (stack === 'autoware') return autowareDockerImage
if (stack === 'nav2') return nav2RosBridgeImage
return slamRosBridgeImage
}
function visualizationBridgeResourceContext(stack: VisualizationStack, bridge?: EnvironmentProfileConfig['bridge'] | null): string | null {
if (bridge?.buildContext?.trim()) return bridge.buildContext.trim()
if (stack === 'autoware') return 'resources/autoware-ros-bridge'
if (stack === 'nav2') return 'resources/nav2-ros-bridge'
return 'resources/slam-ros-bridge'
}
function resolveVisualizationBridgeImage(
stack: VisualizationStack,
bridge?: EnvironmentProfileConfig['bridge'] | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): string {
const configuredImage = bridge?.image?.trim()
if (stack === 'autoware') {
if (!configuredImage || configuredImage === autowareRosBridgeImage) {
return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || autowareDockerImage
}
return configuredImage
}
if (stack === 'slam') {
if (!configuredImage || configuredImage === slamRosBridgeImage) {
return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || slamSimulationRuntimeImage
}
return configuredImage
}
return configuredImage || visualizationBridgeImageName(stack)
}
function bridgeContainerCommand(stack: VisualizationStack): string {
const setupWorkspace = stack === 'autoware'
? 'if [ -f /autoware_ws/install/setup.bash ]; then source /autoware_ws/install/setup.bash; fi'
: ''
const bridgePath = stack === 'slam' ? '/app/bridge.py' : '/ros_bridge/bridge.py'
const requirementPath = stack === 'slam' ? '/app/requirements.txt' : '/ros_bridge/requirements.txt'
return [
'source /opt/ros/humble/setup.bash',
setupWorkspace,
`python3 - <<'PY' || python3 -m pip install --no-cache-dir -r ${requirementPath}`,
'import fastapi, uvicorn, websockets',
'PY',
`python3 ${bridgePath}`
].filter(Boolean).join(' && ')
}
function normalizeVisualizationStack(value?: string | null): VisualizationStack {
if (value === 'slam') return 'slam'
return value === 'nav2' ? 'nav2' : 'autoware'
@@ -1554,7 +1745,7 @@ async function startVisualizationBridge(
const target = normalizeRuntimeTarget(resolvedProfile?.target)
const bridge = resolvedProfile?.bridge
const containerName = bridge?.name?.trim() || visualizationBridgeContainerName(stack)
const bridgeImage = bridge?.image?.trim() || visualizationBridgeImageName(stack)
const bridgeImage = resolveVisualizationBridgeImage(stack, bridge, resolvedProfile?.runtimeImages)
const bridgePort = bridge?.port ?? rosBridgePort
const otherContainerNames = (['autoware', 'nav2', 'slam'] as VisualizationStack[])
.filter((item) => item !== stack)
@@ -1585,17 +1776,21 @@ async function startVisualizationBridge(
await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined)
}
await prepareRuntimeImages(resolvedProfile?.runtimeImages)
if (resolvedProfile?.runtime?.containers?.length) {
await ensureEnvironmentRuntime(resolvedProfile, rosDomainId)
const runtimeProfile = {
...resolvedProfile,
runtime: {
...resolvedProfile.runtime,
containers: resolvedProfile.runtime.containers.filter((container) => container.name?.trim() !== containerName)
}
}
await ensureEnvironmentRuntime(runtimeProfile, 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 bridgeContextPath = resolveResourceContextPath(visualizationBridgeResourceContext(stack, bridge) ?? undefined)
if (!bridgeContextPath) throw new Error(`브릿지 리소스를 찾을 수 없습니다: ${visualizationBridgeResourceContext(stack, bridge) ?? stack}`)
if (stack !== 'autoware' && stack !== 'slam') {
await ensureBridgeImage(bridgeImage, visualizationBridgeResourceContext(stack, bridge))
}
const args = [
@@ -1606,6 +1801,8 @@ async function startVisualizationBridge(
'--net=host',
'-v',
'/dev/shm:/dev/shm',
'-v',
`${bridgeContextPath}:${stack === 'slam' ? '/app' : '/ros_bridge'}:ro`,
'-e',
`ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`,
'-e',
@@ -1644,6 +1841,7 @@ async function startVisualizationBridge(
}
args.push(bridgeImage)
args.push('bash', '-lc', bridgeContainerCommand(stack))
await runCommand('docker', args, undefined, undefined, 15000)
await waitForVisualizationBridgeReady(stack, bridgePort)
return readVisualizationBridgeStatus()
@@ -1724,6 +1922,7 @@ async function installModuleFromCache(
folderName?: string | null,
templateRepositoryUrl?: string | null,
studentRepositoryUrl?: string | null,
options?: ModuleInstallOptions | null,
): Promise<{ ok: boolean; module: CachedModuleInfo }> {
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
@@ -1760,6 +1959,7 @@ async function installModuleFromCache(
await cp(sourceRoot, targetRoot, { recursive: true, force: false, errorOnExist: true })
}
}
await applyModulePrebuiltArtifacts(targetRoot, options?.prebuiltArtifacts)
const { name, category } = parseModuleFolderName(targetFolderName)
return {
@@ -1773,6 +1973,8 @@ async function installModuleFromCache(
sourcePath: templateRepositoryUrl?.trim() ? templateRepositoryUrl.trim() : resolve(moduleCacheRoot, targetFolderName),
workspacePath: targetRoot,
templateRepositoryUrl: templateRepositoryUrl ?? null,
sourceType: 'GIT',
prebuiltArtifacts: options?.prebuiltArtifacts ?? null,
parameters: [],
cpu: null,
memory: null,
@@ -1787,6 +1989,7 @@ async function installRemoteWorkspaceModule(
folderName?: string | null,
templateRepositoryUrl?: string | null,
studentRepositoryUrl?: string | null,
options?: ModuleInstallOptions | null,
): Promise<{ ok: boolean; module: CachedModuleInfo }> {
const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim() || moduleName
@@ -1803,6 +2006,16 @@ async function installRemoteWorkspaceModule(
? withGitCredential(remoteStudentRepositoryUrl)
: ''
const targetRoot = remotePathJoin(target.workspaceRoot, targetFolderName)
const prebuiltArtifacts = (options?.prebuiltArtifacts ?? [])
.filter((artifact) => artifact?.url?.trim())
.map((artifact) => ({
url: artifact.url?.trim() ?? '',
fileName: safeArtifactFileName(artifact.url?.trim() ?? '', artifact.fileName),
checksumSha256: artifact.checksumSha256?.trim() ?? '',
extractPath: artifact.extractPath?.trim() ?? '.',
required: Boolean(artifact.required),
}))
const prebuiltJson = JSON.stringify(prebuiltArtifacts)
const script = [
'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
@@ -1811,6 +2024,7 @@ async function installRemoteWorkspaceModule(
`TARGET="$ROOT/$FOLDER"`,
`TEMPLATE_REPO="$(printf %s ${quoteShellSingle(encodeBase64(templateRepositoryWithCredential))} | base64 -d)"`,
`STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`,
`PREBUILT_JSON="$(printf %s ${quoteShellSingle(encodeBase64(prebuiltJson))} | base64 -d)"`,
'mkdir -p "$ROOT"',
'case "$ROOT" in',
' /tmp/autonomy-studio/sessions/*) rm -rf "$TARGET" ;;',
@@ -1829,6 +2043,39 @@ async function installRemoteWorkspaceModule(
' fi',
'fi',
'rm -rf "$TARGET/build" "$TARGET/install" "$TARGET/log"',
'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
'mkdir -p "$ARTIFACT_CACHE"',
'python3 - "$PREBUILT_JSON" "$ARTIFACT_CACHE" "$TARGET" <<\'PY\'',
'import json, os, subprocess, sys',
'artifacts = json.loads(sys.argv[1] or "[]")',
'cache = sys.argv[2]',
'target = sys.argv[3]',
'def tar_args(path, mode):',
' lower = path.lower()',
' if lower.endswith(".tar.zst") or lower.endswith(".tzst"): return ["--zstd", "-tf" if mode == "list" else "-xf"]',
' if lower.endswith(".tar.gz") or lower.endswith(".tgz"): return ["-tzf" if mode == "list" else "-xzf"]',
' return ["-tf" if mode == "list" else "-xf"]',
'for item in artifacts:',
' url = (item.get("url") or "").strip()',
' if not url: continue',
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"',
' path = os.path.join(cache, name)',
' if not os.path.exists(path):',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:',
' import hashlib',
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")',
' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)',
' for entry in entries.splitlines():',
' entry = entry.strip()',
' if entry.startswith("/") or "../" in entry or entry == "..": raise SystemExit(f"unsafe artifact path: {entry}")',
' extract_path = os.path.abspath(os.path.join(target, item.get("extractPath") or "."))',
' if extract_path != target and not extract_path.startswith(target + os.sep): raise SystemExit("artifact extract path is outside module root")',
' os.makedirs(extract_path, exist_ok=True)',
' subprocess.run(["tar", *tar_args(path, "extract"), path, "-C", extract_path], check=True)',
'PY',
'printf "OK\\t%s\\n" "$TARGET"'
].join('\n')
@@ -1845,6 +2092,8 @@ async function installRemoteWorkspaceModule(
sourcePath: normalizedTemplateRepositoryUrl,
workspacePath: targetRoot,
templateRepositoryUrl: normalizedTemplateRepositoryUrl,
sourceType: 'GIT',
prebuiltArtifacts: options?.prebuiltArtifacts ?? null,
parameters: [],
cpu: null,
memory: null,
@@ -2029,6 +2278,7 @@ async function startWorkspaceModule(
rosParamsOverride?: string | null,
gpuEnabled?: boolean | null,
rosDomainId?: string | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ ok: boolean; message: string }> {
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
@@ -2042,6 +2292,7 @@ async function startWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
await prepareRuntimeImages(runtimeImages)
await ensureStudioRuntimeAssets()
const scriptPath = resolveAutowareDockerRunScript()
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
@@ -2084,6 +2335,7 @@ async function startRemoteWorkspaceModule(
rosParamsOverride?: string | null,
gpuEnabled?: boolean | null,
rosDomainId?: string | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ ok: boolean; message: string }> {
const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim()
@@ -2091,6 +2343,16 @@ async function startRemoteWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
const runtimeImageArtifacts = (runtimeImages ?? [])
.filter((image) => image?.image?.trim())
.map((image) => ({
image: image.image?.trim() ?? '',
url: image.url?.trim() ?? '',
fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '',
checksumSha256: image.checksumSha256?.trim() ?? '',
required: Boolean(image.required),
}))
const runtimeImageJson = JSON.stringify(runtimeImageArtifacts)
const existsOutput = await runSshOutput(target, [
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
@@ -2102,6 +2364,43 @@ async function startRemoteWorkspaceModule(
if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.')
const runtimeRoot = await ensureRemoteStudioRuntimeAssets(target)
await runSsh(
target,
[
'set -e',
`RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(runtimeImageJson))} | base64 -d)"`,
'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
'mkdir -p "$ARTIFACT_CACHE"',
'python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'',
'import json, os, subprocess, sys',
'images = json.loads(sys.argv[1] or "[]")',
'cache = sys.argv[2]',
'for item in images:',
' image = (item.get("image") or "").strip()',
' if not image: continue',
' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0',
' if exists: continue',
' url = (item.get("url") or "").strip()',
' if not url:',
' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")',
' continue',
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"',
' path = os.path.join(cache, name)',
' if not os.path.exists(path): subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:',
' import hashlib',
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")',
' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):',
' import shlex',
' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)',
' else:',
' subprocess.run(["docker", "load", "-i", path], check=True)',
'PY'
].join('\n'),
900000
)
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()) {
@@ -2292,9 +2591,10 @@ async function restartWorkspaceModule(
rosParamsOverride?: string | null,
gpuEnabled?: boolean | null,
rosDomainId?: string | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ ok: boolean; message: string }> {
await stopWorkspaceModule(moduleName, workspaceRoot, folderName)
return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId)
return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages)
}
async function restartRemoteWorkspaceModule(
@@ -2304,9 +2604,10 @@ async function restartRemoteWorkspaceModule(
rosParamsOverride?: string | null,
gpuEnabled?: boolean | null,
rosDomainId?: string | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ ok: boolean; message: string }> {
await stopRemoteWorkspaceModule(target, folderName)
return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId)
return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages)
}
async function readWorkspaceModuleLogs(
@@ -2874,22 +3175,22 @@ function createWindow(): void {
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:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
ipcMain.handle('local-workspace:modules: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:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
ipcMain.handle('local-workspace:modules: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:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
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:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
ipcMain.handle('remote-workspace:modules: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:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
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))
@@ -3045,13 +3346,13 @@ app.whenReady().then(() => {
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:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
ipcMain.handle('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:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
ipcMain.handle('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:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
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())