From 978b782606418177314587402721ac609f640b58 Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 15 Sep 2026 10:50:25 +0900 Subject: [PATCH] feat: download ROS bridge images from R2 and show startup progress --- src/main/bridgeArtifacts.test.ts | 22 ++++++ src/main/bridgeArtifacts.ts | 27 +++++++ src/main/index.ts | 76 +++++++++++++------ src/main/visualizationProgress.ts | 7 ++ src/preload/index.ts | 18 ++++- src/renderer/src/App.tsx | 5 ++ .../src/components/DockerSetupNotice.tsx | 21 ++++- src/renderer/src/env.d.ts | 4 +- .../workspace/WorkspaceSettingsView.tsx | 2 +- .../workspace/WorkspaceVisualizationPanel.tsx | 10 ++- .../workspace/useWorkspaceVisualization.ts | 7 +- 11 files changed, 162 insertions(+), 37 deletions(-) create mode 100644 src/main/bridgeArtifacts.test.ts create mode 100644 src/main/bridgeArtifacts.ts create mode 100644 src/main/visualizationProgress.ts diff --git a/src/main/bridgeArtifacts.test.ts b/src/main/bridgeArtifacts.test.ts new file mode 100644 index 0000000..4f7e1bf --- /dev/null +++ b/src/main/bridgeArtifacts.test.ts @@ -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) +}) diff --git a/src/main/bridgeArtifacts.ts b/src/main/bridgeArtifacts.ts new file mode 100644 index 0000000..69bd417 --- /dev/null +++ b/src/main/bridgeArtifacts.ts @@ -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) +} diff --git a/src/main/index.ts b/src/main/index.ts index bb173ce..b8ada58 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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 { @@ -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 { + 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 { 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 { - 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 { + 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)) diff --git a/src/main/visualizationProgress.ts b/src/main/visualizationProgress.ts new file mode 100644 index 0000000..de907f0 --- /dev/null +++ b/src/main/visualizationProgress.ts @@ -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) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 41cfa8c..8cf7289 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 46276bf..5738d9e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -406,6 +406,7 @@ type WorkspaceVisualizationHost = { available: boolean running: boolean busy: boolean + statusMessage: string viewerUrl: string onStart: () => Promise onTerminate: () => Promise @@ -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()} diff --git a/src/renderer/src/components/DockerSetupNotice.tsx b/src/renderer/src/components/DockerSetupNotice.tsx index 4027bd5..eb5bcf0 100644 --- a/src/renderer/src/components/DockerSetupNotice.tsx +++ b/src/renderer/src/components/DockerSetupNotice.tsx @@ -4,6 +4,8 @@ import { createPortal } from 'react-dom' type DockerResult = Awaited> +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(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(() => { diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index b5df336..0f2e1f9 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -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: { diff --git a/src/renderer/src/features/workspace/WorkspaceSettingsView.tsx b/src/renderer/src/features/workspace/WorkspaceSettingsView.tsx index 0424b9c..180d11c 100644 --- a/src/renderer/src/features/workspace/WorkspaceSettingsView.tsx +++ b/src/renderer/src/features/workspace/WorkspaceSettingsView.tsx @@ -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 가속을 끄세요. diff --git a/src/renderer/src/features/workspace/WorkspaceVisualizationPanel.tsx b/src/renderer/src/features/workspace/WorkspaceVisualizationPanel.tsx index a8d45ba..dc5dd98 100644 --- a/src/renderer/src/features/workspace/WorkspaceVisualizationPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceVisualizationPanel.tsx @@ -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) => 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" > - - {busy ? '처리 중' : '실행하기'} + {busy ?