Compare commits

..

2 Commits

Author SHA1 Message Date
chyoo 76a87fc20d version up 2026-09-15 10:51:23 +09:00
chyoo 978b782606 feat: download ROS bridge images from R2 and show startup progress 2026-09-15 10:50:25 +09:00
12 changed files with 163 additions and 38 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.1.26",
"version": "1.1.27",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
+22
View File
@@ -0,0 +1,22 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
test('PC and Jetson bridges have separate pinned downloads', () => {
const pc = bridgeArtifact(localBridgeImage)!
const jetson = bridgeArtifact(jetsonBridgeImage)!
assert.equal(pc.architecture, 'amd64')
assert.equal(jetson.architecture, 'arm64')
assert.notEqual(pc.url, jetson.url)
assert.notEqual(pc.fileName, jetson.fileName)
for (const artifact of [pc, jetson]) {
assert.match(artifact.checksumSha256, /^[a-f0-9]{64}$/)
assert.equal(new URL(artifact.url).protocol, 'https:')
assert.ok(artifact.url.includes(artifact.checksumSha256.slice(0, 12)))
assert.equal(artifact.required, true)
}
})
test('custom images do not resolve to an unrelated built-in artifact', () => {
assert.equal(bridgeArtifact('custom-bridge:test'), undefined)
})
+27
View File
@@ -0,0 +1,27 @@
const root = 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge'
export const localBridgeImage = 'slam-ros-bridge:20260813-3d-map-v2'
export const jetsonBridgeImage = 'slam-ros-bridge:jetson_orin'
const artifacts = [
{
image: localBridgeImage,
architecture: 'amd64',
url: `${root}/amd64/20260915-489a3e8b370c/slam-ros-bridge-amd64.tar.zst`,
fileName: 'slam-ros-bridge-amd64-489a3e8b370c.tar.zst',
checksumSha256: '489a3e8b370c19903043a39e92935f2e32e4b4423c28210f130df1acab1a9889',
required: true,
},
{
image: jetsonBridgeImage,
architecture: 'arm64',
url: `${root}/arm64/20260819-0659d4cf5578/slam-ros-bridge-jetson-orin.tar.zst`,
fileName: 'slam-ros-bridge-jetson-orin-0659d4cf5578.tar.zst',
checksumSha256: '0659d4cf5578c43bc9c25e4a4fc5d226ccd5b7bec43becfdff83b5390717114e',
required: true,
},
]
export function bridgeArtifact(image: string) {
return artifacts.find((artifact) => artifact.image === image)
}
+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))
+7
View File
@@ -0,0 +1,7 @@
import { AsyncLocalStorage } from 'node:async_hooks'
export const visualizationProgress = new AsyncLocalStorage<(message: string) => void>()
export function reportVisualizationProgress(message: string): void {
visualizationProgress.getStore()?.(message)
}
+16 -2
View File
@@ -2,6 +2,20 @@ import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { readFileSync, rmSync } from 'fs'
let visualizationRequestId = 0
async function invokeVisualizationStart(channel: string, args: unknown[], onProgress?: (message: string) => void) {
const requestId = `${Date.now()}:${++visualizationRequestId}`
const listener = (_event: Electron.IpcRendererEvent, id: string, message: string) => {
if (id === requestId) onProgress?.(message)
}
ipcRenderer.on('visualization:progress', listener)
try {
return await ipcRenderer.invoke(channel, ...args, requestId)
} finally {
ipcRenderer.removeListener('visualization:progress', listener)
}
}
type LauncherAuthPayload = {
appUrl?: string | null
handoffToken?: string | null
@@ -232,7 +246,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
},
visualization: {
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath),
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, onProgress?: (message: string) => void) => invokeVisualizationStart('local-workspace:visualization:start', [stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath], onProgress),
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
},
git: {
@@ -303,7 +317,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
},
visualization: {
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile, rosNetwork),
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, onProgress?: (message: string) => void) => invokeVisualizationStart('remote-workspace:visualization:start', [target, stack, rosDomainId, environmentProfile, rosNetwork], onProgress),
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
},
git: {
+5
View File
@@ -406,6 +406,7 @@ type WorkspaceVisualizationHost = {
available: boolean
running: boolean
busy: boolean
statusMessage: string
viewerUrl: string
onStart: () => Promise<void>
onTerminate: () => Promise<void>
@@ -1052,6 +1053,7 @@ function LocalPcWorkspaceMockup({
setVisualizationStack,
visualizationRunning,
visualizationBusy,
visualizationStatusMessage,
visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
@@ -2258,6 +2260,7 @@ function LocalPcWorkspaceMockup({
available: visualizationStacks.length > 0,
running: visualizationRunning,
busy: visualizationBusy,
statusMessage: visualizationStatusMessage,
viewerUrl: visualizationViewerUrl,
onStart: () => startVisualizationBridge(),
onTerminate: async () => {
@@ -2285,6 +2288,7 @@ function LocalPcWorkspaceMockup({
textDim,
textStrong,
visualizationBusy,
visualizationStatusMessage,
visualizationRunning,
visualizationStacks.length,
visualizationViewerUrl,
@@ -6249,6 +6253,7 @@ export default function App(): React.JSX.Element {
available={workspaceVisualizationHost.available}
running={workspaceVisualizationHost.running}
busy={workspaceVisualizationHost.busy}
statusMessage={workspaceVisualizationHost.statusMessage}
viewerUrl={workspaceVisualizationHost.viewerUrl}
onDragStart={(event) => workspaceVisualizationHostRef.current?.onDragStart(event)}
onToggleLarge={() => workspaceVisualizationHostRef.current?.onToggleLarge()}
@@ -4,6 +4,8 @@ import { createPortal } from 'react-dom'
type DockerResult = Awaited<ReturnType<Window['localWorkspace']['docker']['status']>>
const DOCKER_NOTICE_SEEN_KEY = 'lms:docker-setup-notice-seen'
const messages = {
missing: 'Docker 명령을 찾을 수 없습니다.',
permission: '현재 사용자의 Docker 접근 권한을 확인해주세요.',
@@ -14,7 +16,13 @@ const messages = {
export default function DockerSetupNotice(): React.JSX.Element | null {
const [result, setResult] = useState<DockerResult | null>(null)
const [dismissed, setDismissed] = useState(false)
const [dismissed, setDismissed] = useState(() => {
try {
return localStorage.getItem(DOCKER_NOTICE_SEEN_KEY) === 'true'
} catch {
return false
}
})
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const [actionError, setActionError] = useState('')
@@ -22,6 +30,7 @@ export default function DockerSetupNotice(): React.JSX.Element | null {
const retryPending = useRef(false)
useEffect(() => {
if (dismissed) return
const docker = window.localWorkspace?.docker
if (!docker) return
let active = true
@@ -31,11 +40,17 @@ export default function DockerSetupNotice(): React.JSX.Element | null {
if (active) setResult({ status: 'error', platform: '' })
})
return () => { active = false }
}, [])
}, [dismissed])
const visible = Boolean(result && result.status !== 'ready' && !dismissed)
useEffect(() => {
if (visible) dialogRef.current?.showModal()
if (!visible || !dialogRef.current) return
dialogRef.current.showModal()
try {
localStorage.setItem(DOCKER_NOTICE_SEEN_KEY, 'true')
} catch {
// Keep the notice dismissible when browser storage is unavailable.
}
}, [visible])
useEffect(() => {
+2 -2
View File
@@ -243,7 +243,7 @@ interface Window {
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
message?: string
}>
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => Promise<{
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, onProgress?: (message: string) => void) => Promise<{
ok: boolean
running: boolean
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
@@ -354,7 +354,7 @@ interface Window {
}
visualization: {
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, onProgress?: (message: string) => void) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
}
git: {
@@ -109,7 +109,7 @@ function WorkspaceSettingsView({
'relative ml-3 rounded-md border px-3 py-2 text-[11px] font-semibold leading-relaxed',
'before:absolute before:-top-1 before:left-4 before:size-2 before:rotate-45 before:border-l before:border-t',
dark
? 'border-amber-300/20 bg-amber-300/[0.08] text-amber-100/80 before:border-amber-300/20 before:bg-[#252525]'
? 'border-amber-300/20 bg-[#363328] text-amber-100/80 before:border-amber-300/20 before:bg-[#363328]'
: 'border-amber-300/50 bg-amber-50 text-amber-900/75 before:border-amber-300/50 before:bg-amber-50'
].join(' ')}>
NVIDIA Container Toolkit과 Docker GPU 런타임이 준비된 PC에서만 실행됩니다. 준비되지 않은 경우 GPU 가속을 끄세요.
@@ -1,4 +1,4 @@
import { CirclePlay, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
import { CirclePlay, LoaderCircle, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
import type React from 'react'
import { useEffect, useState } from 'react'
@@ -16,6 +16,7 @@ type WorkspaceVisualizationPanelProps = {
available: boolean
running: boolean
busy: boolean
statusMessage: string
viewerUrl: string
onDragStart: (event: React.PointerEvent<HTMLDivElement>) => void
onToggleLarge: () => void
@@ -38,6 +39,7 @@ export default function WorkspaceVisualizationPanel({
available,
running,
busy,
statusMessage,
viewerUrl,
onDragStart,
onToggleLarge,
@@ -154,10 +156,10 @@ export default function WorkspaceVisualizationPanel({
type="button"
disabled={busy}
onClick={onStart}
className="absolute left-1/2 top-1/2 inline-flex h-28 w-40 -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 text-[15px] font-bold text-white/56 transition hover:text-white disabled:cursor-default disabled:opacity-45 disabled:hover:text-white/56"
className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default"
>
<CirclePlay size={40} />
{busy ? '처리 중' : '실행하기'}
{busy ? <LoaderCircle size={40} className="animate-spin" aria-hidden="true" /> : <CirclePlay size={40} />}
<span role="status" aria-live="polite">{busy ? statusMessage : '실행하기'}</span>
</button>
<div className="absolute inset-x-0 top-[70%] px-5 text-center">
<p className="text-[13px] font-bold text-white/72">지도와 차량 상태가 표시됩니다.</p>
@@ -77,6 +77,7 @@ type UseWorkspaceVisualizationResult = {
setVisualizationStack: (stack: VisualizationStack) => void
visualizationRunning: boolean
visualizationBusy: boolean
visualizationStatusMessage: string
visualizationViewerUrl: string
nav2VisualizationMode: Nav2VisualizationMode
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
@@ -117,7 +118,7 @@ export function useWorkspaceVisualization({
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>(fallbackStack)
const [nav2VisualizationMode, setNav2VisualizationMode] = useState<Nav2VisualizationMode>('slam')
const [visualizationRunning, setVisualizationRunning] = useState(false)
const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationStatusMessage, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => {
@@ -253,7 +254,7 @@ export function useWorkspaceVisualization({
const bridgeSavedMapDatabasePath = preparedMap?.savedMapDatabasePath ?? savedMapDatabasePath
const status = bridgeRunsOnPlatform
? bridgeTarget?.host
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork, setVisualizationStatusMessage)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.start(
visualizationRuntimeStack,
@@ -263,6 +264,7 @@ export function useWorkspaceVisualization({
rosNetwork,
bridgeSavedMapDatabasePath,
preparedMap?.savedMapWorldLayoutPath,
setVisualizationStatusMessage,
)
if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running))
@@ -310,6 +312,7 @@ export function useWorkspaceVisualization({
setVisualizationStack: selectVisualizationStack,
visualizationRunning,
visualizationBusy,
visualizationStatusMessage,
visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,