feat: download ROS bridge images from R2 and show startup progress

This commit is contained in:
2026-09-15 10:50:25 +09:00
parent 54ef5836b1
commit 978b782606
11 changed files with 162 additions and 37 deletions
+53 -23
View File
@@ -1,10 +1,12 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { checkDockerStatus } from './dockerStatus'
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
import type { WebContents } from 'electron'
import { spawn } from 'child_process'
import * as pty from 'node-pty'
import type { IPty } from 'node-pty'
import { appendFileSync, createWriteStream, existsSync, mkdirSync } from 'fs'
import { appendFileSync, createReadStream, createWriteStream, existsSync, mkdirSync } 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'
@@ -12,6 +14,7 @@ import { is } from '@electron-toolkit/utils'
import { createHash } from 'crypto'
import http from 'http'
import https from 'https'
import { pipeline } from 'node:stream/promises'
let mainWindow: BrowserWindow | null = null
let splashWindow: BrowserWindow | null = null
@@ -35,7 +38,7 @@ const autowareRosBridgeImage = 'autoware-ros-bridge'
// Keep the bundled bridge image revisioned. Reusing `latest` leaves an old
// Docker image in place after an LMS update, even when bridge dependencies or
// protocol code changed.
const slamRosBridgeImage = 'slam-ros-bridge:20260813-3d-map-v2'
const slamRosBridgeImage = localBridgeImage
const slamSimulationRuntimeImage = 'future-nav2-gazebo:humble'
const slamSimulationRuntimeContainerName = 'nav2-slam'
const rosBridgePort = 8765
@@ -658,7 +661,7 @@ async function ensureSlamRosBridgeImage(): Promise<void> {
const exists = await runCommand('docker', ['image', 'inspect', slamRosBridgeImage], undefined, undefined, 10_000)
.then(() => true)
.catch(() => false)
if (!exists) await ensureBridgeImage(slamRosBridgeImage, 'resources/slam-ros-bridge')
if (!exists) await ensureBridgeImage(slamRosBridgeImage)
}
async function exportWorkspaceMapPointCloud(workspaceRoot: string, databaseFileName: string): Promise<Buffer> {
@@ -1906,14 +1909,19 @@ async function downloadArtifact(url: string, targetPath: string, redirects = 0):
}
const file = createWriteStream(targetPath)
response.pipe(file)
file.on('finish', () => file.close(() => resolvePromise()))
file.on('error', rejectPromise)
void pipeline(response, file).then(resolvePromise, rejectPromise)
})
request.setTimeout(60000, () => request.destroy(new Error('artifact download timed out')))
request.on('error', rejectPromise)
})
}
async function artifactSha256(filePath: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filePath)) hash.update(chunk)
return hash.digest('hex')
}
async function ensureArtifactFile(url?: string | null, fileName?: string | null, checksumSha256?: string | null): Promise<string | null> {
const artifactUrl = url?.trim()
if (!artifactUrl) return null
@@ -1924,14 +1932,18 @@ async function ensureArtifactFile(url?: string | null, fileName?: string | null,
// Do not reuse a same-named local file, or a source update can run with an
// older install tree even though R2 already contains the current artifact.
await rm(targetPath, { force: true })
reportVisualizationProgress('다운로드 중...')
await downloadArtifact(artifactUrl, targetPath)
if (expected) {
let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
reportVisualizationProgress('다운로드 파일 확인 중...')
let actual = await artifactSha256(targetPath)
if (actual !== expected) {
// Artifact URLs are stable, so an existing filename can be an older cached release.
await rm(targetPath, { force: true })
reportVisualizationProgress('다운로드 중...')
await downloadArtifact(artifactUrl, targetPath)
actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
reportVisualizationProgress('다운로드 파일 확인 중...')
actual = await artifactSha256(targetPath)
if (actual !== expected) {
await rm(targetPath, { force: true }).catch(() => undefined)
throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`)
@@ -2000,12 +2012,14 @@ async function prepareRuntimeImages(images?: RuntimeImageArtifact[] | null): Pro
if (image.required) throw new Error(`runtime image artifact url is missing: ${imageName}`)
continue
}
reportVisualizationProgress('이미지 불러오는 중...')
if (filePath.toLowerCase().endsWith('.tar.zst') || filePath.toLowerCase().endsWith('.tzst')) {
const zstdPath = getBundledZstdPath() ?? 'zstd'
await runCommand('bash', ['-lc', '"$1" -dc "$2" | docker load', 'autonomy-studio-runtime-image', zstdPath, filePath], undefined, undefined, 900000)
await runCommand('bash', ['-o', 'pipefail', '-lc', '"$1" -dc "$2" | docker load', 'autonomy-studio-runtime-image', zstdPath, filePath], undefined, undefined, 900000)
} else {
await runCommand('docker', ['load', '-i', filePath], undefined, undefined, 900000)
}
reportVisualizationProgress('시각화 시작 중...')
}
}
@@ -2378,11 +2392,16 @@ async function startRemoteVisualizationBridge(
))
?.image
?.trim()
if (stack !== 'autoware' && image === slamRosBridgeImage && remoteJetsonBridgeImage) {
image = remoteJetsonBridgeImage
if (stack !== 'autoware' && image === slamRosBridgeImage) {
image = remoteJetsonBridgeImage || jetsonBridgeImage
}
const imageArtifact = environmentProfile?.runtimeImages?.find((artifact) => artifact?.image?.trim() === image)
if (imageArtifact?.url?.trim()) {
const imageArtifact = bridgeArtifact(image)
?? environmentProfile?.runtimeImages?.find((artifact) => artifact?.image?.trim() === image)
const remoteImageExists = imageArtifact?.url?.trim()
? (await runSshOutput(target, `docker image inspect ${quoteShellSingle(image)} >/dev/null 2>&1 && printf present || true`, 10000)).trim() === 'present'
: false
if (imageArtifact?.url?.trim() && !remoteImageExists) {
reportVisualizationProgress('다운로드 및 이미지 준비 중...')
await runSsh(target, [
'set -e',
`IMAGE=${quoteShellSingle(image)}`,
@@ -2396,13 +2415,18 @@ async function startRemoteVisualizationBridge(
'if [ -f "$ARCHIVE" ] && [ -n "$EXPECTED_SHA256" ]; then',
' [ "$(sha256sum "$ARCHIVE" | awk \'{print $1}\')" = "$EXPECTED_SHA256" ] || rm -f "$ARCHIVE"',
'fi',
'if [ ! -f "$ARCHIVE" ]; then curl -fL "$URL" -o "$ARCHIVE"; fi',
'if [ ! -f "$ARCHIVE" ]; then',
' PART=$(mktemp "$ARCHIVE.XXXXXX")',
' trap \'rm -f "$PART"\' EXIT',
' curl -fL --connect-timeout 15 --max-time 750 "$URL" -o "$PART"',
' mv "$PART" "$ARCHIVE"',
'fi',
'if [ -n "$EXPECTED_SHA256" ] && [ "$(sha256sum "$ARCHIVE" | awk \'{print $1}\')" != "$EXPECTED_SHA256" ]; then',
' echo "시각화 이미지 체크섬이 일치하지 않습니다: $FILE_NAME" >&2',
' exit 1',
'fi',
'case "$ARCHIVE" in',
' *.tar.zst|*.tzst) zstd -dc "$ARCHIVE" | docker load ;;',
' *.tar.zst|*.tzst) bash -o pipefail -c \'zstd -dc "$1" | docker load\' _ "$ARCHIVE" ;;',
' *) docker load -i "$ARCHIVE" ;;',
'esac',
'docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "시각화 이미지 로드에 실패했습니다: $IMAGE" >&2; exit 1; }',
@@ -2410,6 +2434,8 @@ async function startRemoteVisualizationBridge(
}
const command = bridgeContainerCommand(stack)
reportVisualizationProgress('시각화 시작 중...')
await runSsh(target, [
'set -e',
`ROOT=${quoteShellSingle(root)}`,
@@ -3513,11 +3539,15 @@ async function resolveDockerResourceContextPath(contextPath = 'resources/slam-ro
return targetPath
}
async function ensureBridgeImage(image: string, buildContext?: string | null): Promise<void> {
if (!buildContext) return
const contextPath = await resolveDockerResourceContextPath(buildContext)
if (!contextPath) throw new Error(`브릿지 Docker context를 찾을 수 없습니다: ${buildContext}`)
await runCommand('docker', ['build', '-t', image, contextPath], undefined, undefined, 120000)
async function ensureBridgeImage(image: string): Promise<void> {
const artifact = bridgeArtifact(image)
if (!artifact) throw new Error(`브리지 이미지의 다운로드 설정이 없습니다: ${image}`)
const architecture = process.arch === 'x64' ? 'amd64' : process.arch
if (architecture !== artifact.architecture) {
throw new Error(`브리지 이미지 아키텍처가 PC와 다릅니다: ${artifact.architecture} / ${architecture}`)
}
await prepareRuntimeImages([artifact])
await runCommand('docker', ['image', 'inspect', image], undefined, undefined, 10000)
}
function expandDockerVolume(volume: string): string {
@@ -3823,7 +3853,7 @@ async function startVisualizationBridge(
.then(() => true)
.catch(() => false)
if (!bridgeImageExists && stack !== 'autoware' && stack !== 'slam') {
await ensureBridgeImage(bridgeImage, visualizationBridgeResourceContext(stack, bridge))
await ensureBridgeImage(bridgeImage)
}
const args = [
@@ -6327,7 +6357,7 @@ function registerLocalWorkspaceHandlers(): void {
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, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target))
ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork))
ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork)))
ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target))
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, repositoryUrl?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))
@@ -6370,7 +6400,7 @@ function registerLocalWorkspaceHandlers(): void {
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, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath))
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath)))
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, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))