feat: download ROS bridge images from R2 and show startup progress
This commit is contained in:
+53
-23
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user