(fix) bug remote workspace ssh..

This commit is contained in:
2026-09-03 15:39:19 +09:00
parent b051ac75cf
commit cb1106f319
11 changed files with 271 additions and 179 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.1.18",
"version": "1.1.19",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
+65 -10
View File
@@ -22,6 +22,8 @@ const terminalStopTimers = new Map<string, NodeJS.Timeout>()
const intentionalTerminalStops = new Set<string>()
const nav2RuntimeObstacleNames = new Set<string>()
const remoteSshQueues = new Map<string, Promise<void>>()
const remoteGitModuleRoots = new Map<string, string>()
const remoteSshControlDirectory = resolve(homedir(), '.autonomy-studio', 'ssh', 'control')
const moduleCacheRoot = resolve(homedir(), '.caches')
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
@@ -367,6 +369,19 @@ function normalizeWorldLayout(value: unknown): WorldLayout {
return { version: 1, bounds: { width: size(bounds.width, 10), depth: size(bounds.depth, 10) }, objects }
}
function isSavedMapCollisionObject(object: WorldLayoutObject): boolean {
return /^saved-(?:map-obstacle|3d-map-voxel)-/i.test(object.id)
}
// Saved-map collision layouts used to be copied into world-layout.json. Keep
// that file exclusively for the custom map and discard only those old generated
// objects while preserving any user-created objects that may coexist there.
function normalizeCustomWorldLayout(value: unknown): WorldLayout {
const layout = normalizeWorldLayout(value)
const objects = layout.objects.filter((object) => !isSavedMapCollisionObject(object))
return objects.length === layout.objects.length ? layout : { ...layout, objects }
}
function worldLayoutPath(workspaceRoot?: string | null): string {
const root = expandUserPath(workspaceRoot)
if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
@@ -527,7 +542,6 @@ async function generateWorkspaceMapCollisionLayout(workspaceRoot: string | null
const layout = createMapCollisionLayout(await readFile(yamlFilePath, 'utf8'), await readFile(pgmFilePath))
await mkdir(mapsDirectory, { recursive: true })
await writeJsonAtomically(resolve(mapsDirectory, savedMapCollisionLayoutFileName(yamlFileName)), layout)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
@@ -681,7 +695,6 @@ async function generateWorkspacePointCloudCollisionLayout(
&& cached.source.modifiedAtMs === sourceStat.mtimeMs
&& cached.layout) {
const layout = normalizeWorldLayout(cached.layout)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
} catch {
@@ -697,7 +710,6 @@ async function generateWorkspacePointCloudCollisionLayout(
source: { fileName: databaseFileName, size: sourceStat.size, modifiedAtMs: sourceStat.mtimeMs },
layout,
} satisfies PointCloudLayoutCache)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
@@ -1365,7 +1377,7 @@ async function collectRemoteAvailableModules(
}
})
.sort((a, b) => a.sortOrder - b.sortOrder)
const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 15000)
const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 30_000)
const dockerStatuses = new Map<string, { running: boolean; exists: boolean; status: string }>()
const dockerStats = new Map<string, { cpu: number; memory: number }>()
const installedModules = new Map<string, { path: string; parameters: string[] }>()
@@ -1843,6 +1855,7 @@ async function queueRemoteSsh<T>(target: ReturnType<typeof normalizeRemoteTarget
async function runSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs = 120_000): Promise<string> {
const normalized = normalizeRemoteTarget(target)
await mkdir(remoteSshControlDirectory, { recursive: true })
return queueRemoteSsh(normalized, () => runCommandOutput(
'ssh',
[
@@ -1854,6 +1867,18 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
'StrictHostKeyChecking=accept-new',
'-o',
'ConnectTimeout=5',
'-o',
'PreferredAuthentications=publickey',
'-o',
'PasswordAuthentication=no',
'-o',
'KbdInteractiveAuthentication=no',
'-o',
'ControlMaster=auto',
'-o',
'ControlPersist=120',
'-o',
`ControlPath=${join(remoteSshControlDirectory, 'cm-%C')}`,
'-i',
normalized.identityFile,
`${normalized.user}@${normalized.host}`,
@@ -2123,10 +2148,10 @@ function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModu
'set +e',
`ROOT=${quoteShellSingle(workspaceRoot)}`,
...expandRemotePathVar('ROOT'),
'timeout 12s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true',
'timeout 5s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true',
// docker stats can stall on a remote Jetson. Module state must not wait
// behind a long statistics sample.
'timeout 3s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
'timeout 2s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
...moduleFolders.map((folderName) => {
const modulePath = remotePathJoin('$ROOT', folderName)
return [
@@ -2344,6 +2369,9 @@ async function resolveRemoteGitModuleRoot(
): Promise<{ target: ReturnType<typeof normalizeRemoteTarget>; moduleRoot: string }> {
const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim() || moduleName
const cacheKey = `${target.user}@${target.host}:${target.port}:${target.workspaceRoot}:${targetFolderName}`
const cachedModuleRoot = remoteGitModuleRoots.get(cacheKey)
if (cachedModuleRoot) return { target, moduleRoot: cachedModuleRoot }
const output = await runSshOutput(target, [
'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
@@ -2356,6 +2384,7 @@ async function resolveRemoteGitModuleRoot(
].join('\n'), 8000)
const moduleRoot = output.trim()
if (!moduleRoot) throw new Error('원격 Git 저장소를 찾을 수 없습니다.')
remoteGitModuleRoots.set(cacheKey, moduleRoot)
return { target, moduleRoot }
}
@@ -3347,6 +3376,7 @@ async function startVisualizationBridge(
environmentProfile?: EnvironmentProfileConfig | null,
rosNetwork?: RosNetworkConfig | null,
savedMapDatabasePath?: string | null,
savedMapWorldLayoutPath?: string | null,
): Promise<VisualizationBridgeStatus> {
const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase())
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
@@ -3358,6 +3388,25 @@ async function startVisualizationBridge(
const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2'
const layoutWorkspaceRoot = usesSlamBridge ? expandUserPath(workspaceRoot) : null
if (layoutWorkspaceRoot) await mkdir(resolve(layoutWorkspaceRoot, 'maps'), { recursive: true })
const savedMapWorldLayoutFileName = savedMapWorldLayoutPath
? safeSavedMapFileName(basename(savedMapWorldLayoutPath))
: null
const requestedWorldLayoutPath = savedMapWorldLayoutFileName
? `/workspace/maps/${savedMapWorldLayoutFileName}`
: '/workspace/world-layout.json'
if (layoutWorkspaceRoot && !savedMapWorldLayoutFileName) {
const customLayoutPath = worldLayoutPath(layoutWorkspaceRoot)
try {
const currentLayout = await readFile(customLayoutPath, 'utf8')
const parsedLayout = JSON.parse(currentLayout)
const normalizedLayout = normalizeCustomWorldLayout(parsedLayout)
if (JSON.stringify(normalizedLayout) !== JSON.stringify(parsedLayout)) {
await writeJsonAtomically(customLayoutPath, normalizedLayout)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
const requestedRosDomainId = String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'
const requestedRosNetworkKey = rosNetworkEnabled(rosNetwork)
? `peer:${rosNetwork?.peer?.host?.trim() ?? ''}`
@@ -3384,10 +3433,16 @@ async function startVisualizationBridge(
const runningWorkspaceRoot = layoutWorkspaceRoot
? await readContainerMountSource(containerName, '/workspace')
: null
const runningWorldLayoutPath = layoutWorkspaceRoot
? await readContainerEnvironmentValue(containerName, 'WORLD_LAYOUT_PATH')
: null
if (response?.ok
&& (!runningRosDomainId || runningRosDomainId === requestedRosDomainId)
&& runningRosNetworkKey === requestedRosNetworkKey
&& (!layoutWorkspaceRoot || runningWorkspaceRoot === resolve(layoutWorkspaceRoot))) {
&& (!layoutWorkspaceRoot || (
runningWorkspaceRoot === resolve(layoutWorkspaceRoot)
&& runningWorldLayoutPath === requestedWorldLayoutPath
))) {
return readVisualizationBridgeStatus()
}
await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined)
@@ -3448,7 +3503,7 @@ async function startVisualizationBridge(
if (layoutWorkspaceRoot) {
args.push(
'-v', `${layoutWorkspaceRoot}:/workspace`,
'-e', 'WORLD_LAYOUT_PATH=/workspace/world-layout.json',
'-e', `WORLD_LAYOUT_PATH=${requestedWorldLayoutPath}`,
'-e', 'SAVED_MAPS_PATH=/workspace/maps',
'-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
)
@@ -5804,7 +5859,7 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('local-workspace:world-layout:read', async (_event, workspaceRoot?: string | null) => {
const targetPath = worldLayoutPath(workspaceRoot)
try {
return normalizeWorldLayout(JSON.parse(await readFile(targetPath, 'utf8')))
return normalizeCustomWorldLayout(JSON.parse(await readFile(targetPath, 'utf8')))
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return defaultWorldLayout()
@@ -5902,7 +5957,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) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath))
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: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))
+1 -1
View File
@@ -226,7 +226,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) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath),
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),
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
},
git: {
+10 -2
View File
@@ -582,7 +582,15 @@ controls.mouseButtons = {
MIDDLE: THREE.MOUSE.ROTATE,
RIGHT: THREE.MOUSE.DOLLY,
};
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
renderer.domElement.addEventListener('contextmenu', (event) => {
event.preventDefault();
if (!obstaclePlacementMode) return;
obstaclePlacementMode = false;
obstacleToggleButton.classList.remove('active');
obstacleToggleButton.setAttribute('aria-pressed', 'false');
hideObstaclePreview();
focusViewer();
});
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0);
@@ -2706,7 +2714,7 @@ function createObstaclePreviewTemplate() {
yaw: (Math.floor(Math.random() * 4) * Math.PI) / 2,
length: 0.7 + Math.random() * 0.9,
depth: 0.16,
height: 0.52,
height: 1.0,
};
}
+134 -122
View File
@@ -29,6 +29,7 @@ import RecentSessionModal from './features/platforms/RecentSessionModal'
import type { RecentSessionItem } from './features/platforms/RecentSessionModal'
import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl'
import type { PlatformConnectResponse } from './features/platforms/platformControl'
import type { EnvironmentProfile, PlatformControlActionResponse, PlatformOption, StudioSourceSettings } from './features/platforms/types'
import WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader'
import WorkspaceEmptyModuleState from './features/workspace/WorkspaceEmptyModuleState'
@@ -44,7 +45,7 @@ import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleT
import ModuleListView from './features/workspace/ModuleListView'
import WorkspaceRosConnectionsView from './features/workspace/WorkspaceRosConnectionsView'
import WorldEditorModal from './features/workspace/WorldEditorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption, type WorkspaceMapScope } from './features/workspace/WorldMapSelectorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
@@ -803,16 +804,13 @@ function LocalPcWorkspaceMockup({
const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected`
const simulationMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:simulation`
const platformMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:platform`
const [selectedSimulationMapKey, setSelectedSimulationMapKey] = useState(() => (
window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(sessionMapStorageKey)
?? 'nav2-turtlebot3-world'
))
const [selectedPlatformMapKey, setSelectedPlatformMapKey] = useState(() => (
window.localStorage.getItem(platformMapStorageKey)
?? window.localStorage.getItem(sessionMapStorageKey)
?? 'nav2-platform-slam-2d'
))
const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => {
const stored = window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(platformMapStorageKey)
if (stored === 'nav2-platform-slam') return 'nav2-platform-slam-2d'
return stored?.replace(/:simulation$/, '') ?? 'nav2-turtlebot3-world'
})
const [savedSlamMaps, setSavedSlamMaps] = useState<WorkspaceMapOption[]>([])
const [savedSlamMapsLoaded, setSavedSlamMapsLoaded] = useState(false)
const [worldLayoutVersion, setWorldLayoutVersion] = useState(0)
@@ -861,6 +859,7 @@ function LocalPcWorkspaceMockup({
.map((map) => ({
...map,
key: `${map.key}:simulation`,
selectionKey: map.key,
description: '저장된 SLAM 결과 맵',
worldFile: 'empty_world.world',
scope: 'simulation' as const,
@@ -874,6 +873,9 @@ function LocalPcWorkspaceMockup({
return true
})
}, [curriculumWeek.maps, savedSlamMaps])
const selectableWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => !map.selectionKey)
), [sessionWorldMaps])
const simulationWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => map.scope === 'simulation')
), [sessionWorldMaps])
@@ -881,41 +883,41 @@ function LocalPcWorkspaceMockup({
sessionWorldMaps.filter((map) => map.scope === 'platform')
), [sessionWorldMaps])
useEffect(() => {
const legacyMapKey = window.localStorage.getItem(sessionMapStorageKey)
const normalizeMapKey = (key: string | null, scope: WorkspaceMapScope): string | null => {
if (key === 'nav2-platform-slam') return scope === 'platform' ? 'nav2-platform-slam-2d' : null
return key
const normalizeMapKey = (key: string | null): string | null => {
if (key === 'nav2-platform-slam') return 'nav2-platform-slam-2d'
return key?.replace(/:simulation$/, '') ?? null
}
const resolveSelection = (
scope: WorkspaceMapScope,
current: string,
storageKey: string,
maps: WorkspaceMapOption[],
fallback: string,
): string => {
const stored = normalizeMapKey(window.localStorage.getItem(storageKey) ?? legacyMapKey, scope)
const requested = normalizeMapKey(current, scope) ?? stored
const stored = normalizeMapKey(
window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(platformMapStorageKey),
)
setSelectedWorldMapKey((current) => {
const requested = normalizeMapKey(current) ?? stored
if (!savedSlamMapsLoaded && requested?.startsWith('saved-slam-')) return current
const next = maps.some((map) => map.key === requested)
const next = selectableWorldMaps.some((map) => map.key === requested)
? requested!
: maps.some((map) => map.key === stored)
: selectableWorldMaps.some((map) => map.key === stored)
? stored!
: maps.find((map) => map.key === fallback)?.key ?? maps[0]?.key ?? fallback
window.localStorage.setItem(storageKey, next)
: selectableWorldMaps.find((map) => map.key === 'nav2-turtlebot3-world')?.key
?? selectableWorldMaps[0]?.key
?? 'nav2-turtlebot3-world'
window.localStorage.setItem(sessionMapStorageKey, next)
return next
}
setSelectedSimulationMapKey((current) => resolveSelection(
'simulation', current, simulationMapStorageKey, simulationWorldMaps, 'nav2-turtlebot3-world',
))
setSelectedPlatformMapKey((current) => resolveSelection(
'platform', current, platformMapStorageKey, platformWorldMaps, 'nav2-platform-slam-2d',
))
}, [platformMapStorageKey, platformWorldMaps, savedSlamMapsLoaded, sessionMapStorageKey, simulationMapStorageKey, simulationWorldMaps])
const selectedWorldMapKey = remoteWorkspace ? selectedPlatformMapKey : selectedSimulationMapKey
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey)
?? (remoteWorkspace ? platformWorldMaps[0] : simulationWorldMaps[0])
})
}, [platformMapStorageKey, savedSlamMapsLoaded, selectableWorldMaps, sessionMapStorageKey, simulationMapStorageKey])
const selectedWorldMap = useMemo(() => {
const localSimulationMapKey = `${selectedWorldMapKey}:simulation`
return (!remoteWorkspace
? sessionWorldMaps.find((map) => map.key === localSimulationMapKey)
: undefined)
?? sessionWorldMaps.find((map) => map.key === selectedWorldMapKey)
?? selectableWorldMaps.find((map) => map.key === 'nav2-turtlebot3-world')
?? selectableWorldMaps[0]
?? NAV2_MAP_OPTIONS[0]
}, [remoteWorkspace, selectableWorldMaps, selectedWorldMapKey, sessionWorldMaps])
const prepareVisualizationSavedMapRef = useRef<(
() => Promise<{ savedMapDatabasePath?: string }>
() => Promise<{ savedMapDatabasePath?: string; savedMapWorldLayoutPath?: string }>
) | null>(null)
const savedMapRunsInSimulation = !remoteWorkspace
&& selectedWorldMap?.saved === true
@@ -2491,7 +2493,7 @@ function LocalPcWorkspaceMockup({
saveDirtyFilesForModule,
onRegisterStopRunningModules
})
const prepareSavedMap = useCallback(async (): Promise<{ savedMapYamlPath?: string; savedMapDatabasePath?: string }> => {
const prepareSavedMap = useCallback(async (): Promise<{ savedMapYamlPath?: string; savedMapDatabasePath?: string; savedMapWorldLayoutPath?: string }> => {
if (!selectedWorldMap.saved) return {}
if (!selectedWorldMap.yamlPath || !selectedWorldMap.pgmPath || !selectedWorldMap.yamlUrl || !selectedWorldMap.pgmUrl) {
throw new Error('이전 형식으로 저장된 맵입니다. SLAM을 다시 저장해 공용 맵으로 변환하세요.')
@@ -2511,6 +2513,7 @@ function LocalPcWorkspaceMockup({
]
let databasePath: string | undefined
let databaseFileName: string | undefined
let savedMapWorldLayoutPath: string | undefined
if (selectedWorldMap.databasePath || selectedWorldMap.databaseUrl) {
databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : ''
if (!databaseFileName.endsWith('.db') || !selectedWorldMap.databaseUrl) {
@@ -2534,13 +2537,16 @@ function LocalPcWorkspaceMockup({
if (selectedWorldMap.runtimeKind === 'PLATFORM_3D') {
if (!databaseFileName) throw new Error('시뮬레이션할 3D SLAM 데이터베이스가 없습니다.')
await window.localWorkspace.maps.generatePointCloudCollisionLayout(clonePathPreview, databaseFileName)
savedMapWorldLayoutPath = `/workspace/maps/${databaseFileName.replace(/\.db$/i, '.gazebo-3d-layout.json')}`
} else {
await window.localWorkspace.maps.generateCollisionLayout(clonePathPreview, yamlFileName, pgmFileName)
savedMapWorldLayoutPath = `/workspace/maps/${yamlFileName.replace(/\.yaml$/i, '.gazebo-layout.json')}`
}
}
return {
savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
savedMapDatabasePath: databasePath,
savedMapWorldLayoutPath,
}
}, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
prepareVisualizationSavedMapRef.current = prepareSavedMap
@@ -2614,23 +2620,13 @@ function LocalPcWorkspaceMockup({
return () => onRegisterRunningModuleNames?.(null)
}, [availableModules, onRegisterRunningModuleNames, onRunningModuleCountChange])
const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => {
const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform')
const storageKey = scope === 'platform' ? platformMapStorageKey : simulationMapStorageKey
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace
window.localStorage.setItem(storageKey, map.key)
if (scope === 'platform') {
setSelectedPlatformMapKey(map.key)
} else {
setSelectedSimulationMapKey(map.key)
}
if (map.custom && map.layout && scope === 'simulation' && !remoteWorkspace) {
const mapKey = map.selectionKey ?? map.key
window.localStorage.setItem(sessionMapStorageKey, mapKey)
setSelectedWorldMapKey(mapKey)
if (map.custom && map.layout && !remoteWorkspace) {
await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout)
setWorldLayoutVersion((version) => version + 1)
}
if (!appliesToActiveWorkspace) {
onTitlebarNotice(`[${map.name}] 맵이 ${scope === 'platform' ? '플랫폼' : '시뮬레이션'} 환경에 선택되었습니다.`, 'info')
return
}
if (map.saved) {
setNav2VisualizationMode('navigation')
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2')
@@ -3922,7 +3918,7 @@ function LocalPcWorkspaceMockup({
initialScope={remoteWorkspace ? 'platform' : 'simulation'}
simulationMaps={simulationWorldMaps}
platformMaps={platformWorldMaps}
selectedMapKeys={{ simulation: selectedSimulationMapKey, platform: selectedPlatformMapKey }}
selectedMapKey={selectedWorldMapKey}
onClose={() => setWorldMapSelectorOpen(false)}
onCreate={() => {
if (remoteWorkspace) {
@@ -3934,11 +3930,9 @@ function LocalPcWorkspaceMockup({
onDelete={(map) => void deleteSavedSlamMap(map)}
onSelect={(map) => {
setWorldMapSelectorOpen(false)
const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform')
const currentMapKey = scope === 'platform' ? selectedPlatformMapKey : selectedSimulationMapKey
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace
if (map.key === currentMapKey) return
if (appliesToActiveWorkspace && availableModules.some((module) => module.state === 'running')) {
const nextMapKey = map.selectionKey ?? map.key
if (nextMapKey === selectedWorldMapKey) return
if (availableModules.some((module) => module.state === 'running')) {
void applyWorldMapSelection(map)
setPendingWorldMapRestart(map)
return
@@ -4199,10 +4193,10 @@ export default function App(): React.JSX.Element {
: null
const hasCurrentUserPlatformControl = (platform: PlatformOption): boolean => {
if (platform.platformKey === 'futuredrive-local-pc') return true
const session = platform.control?.session
// The LMS API calculates ACTIVE for the requesting LMS user. Its session
// holder ID belongs to the LMS database, while authSession.user.id belongs
// to the auth service, so comparing the two client-side rejects the owner.
return platform.control?.status === 'ACTIVE'
&& Boolean(authSession?.user.id)
&& session?.holderUserId === authSession?.user.id
}
useEffect(() => {
if (!publicGuideCourseActive) return
@@ -4319,7 +4313,8 @@ export default function App(): React.JSX.Element {
if (options.notify !== false) showTitlebarNotice('원격 ROS 연결을 초기화했습니다.', 'info')
return true
} catch (error) {
if (options.notify !== false) showTitlebarNotice(error instanceof Error ? error.message : '원격 ROS 연결 초기화에 실패했습니다.', 'error')
const message = error instanceof Error ? error.message : '원격 ROS 연결 초기화에 실패했습니다.'
if (options.notify !== false) showTitlebarNotice(message, 'error')
return false
} finally {
setRemoteRosResetting(false)
@@ -4618,48 +4613,31 @@ export default function App(): React.JSX.Element {
platforms.forEach((platform) => next.add(platform.id))
return next
})
const lostControlPlatformIds = new Set(
runningPlatformsRef.current
.filter((platform) => {
if (platform.platformKey === 'futuredrive-local-pc') return false
const nextControl = statusById.get(platform.id)?.control
if (!nextControl) return false
if (nextControl.status !== 'ACTIVE') return true
const currentSessionId = platform.control?.session?.id
return Boolean(
currentSessionId
&& nextControl.session?.id
&& nextControl.session.id !== currentSessionId,
)
})
.map((platform) => platform.id),
)
const remainingRunningPlatforms = runningPlatformsRef.current
.filter((platform) => !lostControlPlatformIds.has(platform.id))
.map(mergePlatformStatus)
const mergeRunningPlatformStatus = (platform: PlatformOption): PlatformOption => {
const status = statusById.get(platform.id)
if (!status) return platform
if (lostControlPlatformIds.size) {
setRunningPlatforms(remainingRunningPlatforms)
setConnectedPlatform((current) => {
if (current && !lostControlPlatformIds.has(current.id)) return mergePlatformStatus(current)
return remainingRunningPlatforms[0] ?? null
})
setLastWorkspacePlatform(remainingRunningPlatforms[0] ?? null)
if (!remainingRunningPlatforms.length) {
setLocalPcLayoutOpen(false)
setStudioEntered(false)
setSessionStatus('ended')
setSessionEndedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }))
setWorkspaceWeekState(null)
// A status request started before startSession can return the prior
// AVAILABLE snapshot. Do not let that stale response replace an
// opened remote workspace with another running platform (usually the
// local PC). Explicit release, expiry and control-terminated socket
// events remain responsible for closing a workspace.
if (
platform.platformKey !== 'futuredrive-local-pc'
&& platform.control?.status === 'ACTIVE'
&& status.control?.status !== 'ACTIVE'
) {
return {
...platform,
connection: status.connection ?? platform.connection,
}
setCurriculumRefreshSignal((current) => current + 1)
} else {
setRunningPlatforms((current) => current.map(mergePlatformStatus))
setConnectedPlatform((current) => {
if (!current) return current
return mergePlatformStatus(current)
})
}
return mergePlatformStatus(platform)
}
setRunningPlatforms((current) => current.map(mergeRunningPlatformStatus))
setConnectedPlatform((current) => current ? mergeRunningPlatformStatus(current) : current)
} catch {
// Status polling is best-effort; keep the last known platform state.
}
@@ -5469,13 +5447,54 @@ export default function App(): React.JSX.Element {
setSidebarCollapsed(true)
setLocalPcLayoutOpen(true)
}
const mergePlatformEnvironmentOverride = (platform: PlatformOption, override: unknown): PlatformOption => {
if (!override || typeof override !== 'object') return platform
const overrideConfig = override as Record<string, unknown>
const mergePlatformEnvironmentOverride = (platform: PlatformOption, payload: PlatformConnectResponse): PlatformOption => {
const overrideConfig = payload.environmentConfigOverride
const gatewayConnection = payload.gateway?.connection
const gatewaySession = payload.gateway?.session
const connection = gatewayConnection
? {
registered: platform.connection?.registered ?? true,
headscaleOnline: platform.connection?.headscaleOnline ?? true,
agentHealthy: platform.connection?.agentHealthy ?? true,
lastSeenAt: platform.connection?.lastSeenAt ?? null,
message: platform.connection?.message ?? null,
...platform.connection,
agentUrl: gatewayConnection.agentUrl ?? platform.connection?.agentUrl ?? null,
sshUser: gatewayConnection.sshUser ?? platform.connection?.sshUser ?? null,
workspaceRoot: gatewayConnection.workspaceRoot ?? platform.connection?.workspaceRoot ?? null,
}
: platform.connection
const sourceSettings = gatewayConnection
? {
...(platform.sourceSettings ?? {}),
agentUrl: gatewayConnection.agentUrl ?? platform.sourceSettings?.agentUrl ?? null,
sshUser: gatewayConnection.sshUser ?? platform.sourceSettings?.sshUser ?? null,
workspaceRoot: gatewayConnection.workspaceRoot ?? platform.sourceSettings?.workspaceRoot ?? undefined,
}
: platform.sourceSettings
return {
...platform,
environmentConfigOverride: overrideConfig,
environmentProfiles: platform.environmentProfiles?.map((profile) => ({
sourceSettings,
connection,
environmentConfigOverride: overrideConfig ?? platform.environmentConfigOverride,
control: gatewaySession
? {
status: 'ACTIVE',
action: '반납',
warning: false,
session: {
id: gatewaySession.id,
holderUserId: gatewaySession.userId,
startedAt: gatewaySession.startedAt,
endsAt: gatewaySession.expiresAt,
warningAt: gatewaySession.expiresAt,
extensionCount: platform.control?.session?.extensionCount,
},
queue: null,
}
: platform.control,
environmentProfiles: overrideConfig ? platform.environmentProfiles?.map((profile) => ({
...profile,
config: {
...profile.config,
@@ -5489,7 +5508,7 @@ export default function App(): React.JSX.Element {
...(typeof overrideConfig.bridge === 'object' && overrideConfig.bridge !== null ? overrideConfig.bridge as Record<string, unknown> : {})
}
}
})) ?? platform.environmentProfiles
})) ?? platform.environmentProfiles : platform.environmentProfiles
}
}
@@ -5499,7 +5518,7 @@ export default function App(): React.JSX.Element {
if (isPlatformUnavailable(platform)) return platform
const payload = await connectPlatformControl(fetchWithFreshAuth, platform.id)
return mergePlatformEnvironmentOverride(platform, payload.environmentConfigOverride)
return mergePlatformEnvironmentOverride(platform, payload)
}
const applyPlatformControlResponse = (platformId: string, payload: PlatformControlActionResponse): void => {
@@ -5601,16 +5620,9 @@ export default function App(): React.JSX.Element {
})
setPlatformLoading(true)
try {
// Invalidate status requests that started before this remote session.
platformStatusRequestSequenceRef.current += 1
const connected = await connectRemotePlatform(platform)
if (connected.platformKey !== 'futuredrive-local-pc') {
setPlatformLoadingCopy({
title: `${connected.title} ROS 연결 초기화 중`,
description: '이전 제어 세션의 모듈과 피어 연결을 정리하고 있습니다.'
})
if (!await resetRemotePlatformRosSession(connected, { notify: false })) {
throw new Error('원격 ROS 연결 초기화에 실패했습니다.')
}
}
const workspaceWeekSnapshot = selectedWeekState
window.setTimeout(() => {
setConnectedPlatform(connected)
@@ -5630,9 +5642,9 @@ export default function App(): React.JSX.Element {
setSidebarCollapsed(true)
setLocalPcLayoutOpen(true)
}, 2000)
} catch {
} catch (error) {
setPlatformLoading(false)
showTitlebarNotice('플랫폼 연결 정보를 불러오지 못했습니다', 'error')
showTitlebarNotice(error instanceof Error ? error.message : '플랫폼 연결 정보를 불러오지 못했습니다', 'error')
setCurriculumRefreshSignal((current) => current + 1)
}
}
+1 -1
View File
@@ -236,7 +236,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) => Promise<{
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => Promise<{
ok: boolean
running: boolean
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
@@ -30,6 +30,29 @@ export type PlatformControlTerminatedPayload = {
endedAt: string
}
export type PlatformConnectResponse = {
mode?: string
gateway?: {
session?: {
id: string
platformId: string
userId: string
status: string
startedAt: string
expiresAt: string
releasedAt: string | null
}
connection?: {
agentUrl?: string | null
sshUser?: string | null
workspaceRoot?: string | null
bridgeHttpUrl?: string | null
bridgeWsUrl?: string | null
}
}
environmentConfigOverride?: Record<string, unknown>
}
export function isPlatformUnavailable(platform: PlatformOption): boolean {
return (
platform.state === 'locked' ||
@@ -60,12 +83,12 @@ export async function fetchPlatformStatuses(fetchWithFreshAuth: AuthFetch, platf
return payload.platforms ?? []
}
export async function connectPlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<{ environmentConfigOverride?: unknown }> {
export async function connectPlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<PlatformConnectResponse> {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/connect`, {
method: 'POST',
})
if (!response.ok) throw new Error('platform-connect failed')
return response.json() as Promise<{ environmentConfigOverride?: unknown }>
return response.json() as Promise<PlatformConnectResponse>
}
export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<void> {
@@ -349,7 +349,7 @@ export default function ModuleListView({
</div>
</div>
</div>
<div ref={floatingActionMenuRef} className="absolute -left-7 bottom-6 z-[60] h-14 w-14">
<div ref={floatingActionMenuRef} className="absolute -left-7 bottom-6 z-30 h-14 w-14">
<button
type="button"
aria-label="실행 방법 메뉴"
@@ -357,7 +357,7 @@ export default function ModuleListView({
onClick={() => setFloatingActionMenuOpen((open) => !open)}
className={[
'grid h-14 w-14 cursor-pointer place-items-center rounded-full text-center text-[11px] font-bold shadow-[0_10px_26px_rgba(20,54,104,0.28)] transition hover:-translate-y-0.5 hover:scale-[1.05]',
floatingActionMenuOpen ? 'bg-[#4b5563]/85 text-[#c7cfda] hover:bg-[#3f4855]/90' : 'bg-[#2563eb] text-white hover:bg-[#1d4ed8]'
floatingActionMenuOpen ? 'bg-[#4b5563]/85 text-[#c7cfda] hover:bg-[#3f4855]/90' : 'bg-[#2158cc] text-white hover:bg-[#1d4ed8]'
].join(' ')}
>
<span className="text-[24px] font-medium leading-none">+</span>
@@ -6,6 +6,9 @@ export type WorkspaceMapScope = 'simulation' | 'platform'
export type WorkspaceMapOption = {
key: string
// A saved platform map has a local Gazebo representation with a different
// runtime key. Both representations share this user-facing map selection.
selectionKey?: string
platformKey: 'NAV2'
name: string
description: string
@@ -198,25 +201,24 @@ export function WorkspaceMapPreview({
type WorldMapSelectorModalProps = {
weekNumber: number
initialScope?: 'simulation' | 'platform'
initialScope?: WorkspaceMapScope
simulationMaps: WorkspaceMapOption[]
platformMaps: WorkspaceMapOption[]
selectedMapKeys: Record<WorkspaceMapScope, string>
selectedMapKey: string
onSelect: (map: WorkspaceMapOption) => void
onCreate?: () => void
onDelete?: (map: WorkspaceMapOption) => void
onClose: () => void
}
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKeys, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const [scope, setScope] = useState<'simulation' | 'platform'>(initialScope)
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const [scope, setScope] = useState<WorkspaceMapScope>(initialScope)
const maps = scope === 'simulation' ? simulationMaps : platformMaps
const selectedMapKey = selectedMapKeys[scope]
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const selectedMap = maps.find((map) => (map.selectionKey ?? map.key) === selectedMapKey) ?? null
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
useEffect(() => {
setPendingMap(maps.find((map) => map.key === selectedMapKey) ?? null)
setPendingMap(maps.find((map) => (map.selectionKey ?? map.key) === selectedMapKey) ?? null)
}, [maps, selectedMapKey])
useEffect(() => {
@@ -229,7 +231,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차 학습 세션 맵</p>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차 학습 세션 공통 맵</p>
</div>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" />
@@ -242,7 +244,8 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
<div className="thin-y-scrollbar min-h-[400px] max-h-[52vh] space-y-2 overflow-y-auto p-5">
{maps.map((map) => {
const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey
const mapSelectionKey = map.selectionKey ?? map.key
const selected = mapSelectionKey === selectedMapKey
// "새 SLAM" is a launch target, not a saved map yet. Its 2D/3D
// runtime is chosen by the active SLAM stack, so only persisted
// map results receive a dimensional badge.
@@ -279,7 +282,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
</span>
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
{map.saved && map.key !== selectedMapKey && onDelete && (
{map.saved && !selected && onDelete && (
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
<Trash2 className="size-3.5" />
</span>
@@ -54,7 +54,6 @@ export function useWorkspaceTerminal({
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
const terminalSuppressQueueRef = useRef<string[]>([])
const terminalOutputBufferRef = useRef<Record<string, string>>({})
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
@@ -158,7 +157,6 @@ export function useWorkspaceTerminal({
}
terminalElement.addEventListener('contextmenu', handleTerminalContextMenu)
terminalElement.addEventListener('mousedown', focusTerminal)
let terminalReceivedVisibleData = Boolean(terminalOutputBufferRef.current[terminalSessionId])
if (terminalOutputBufferRef.current[terminalSessionId]) {
term.write(terminalOutputBufferRef.current[terminalSessionId])
}
@@ -175,7 +173,6 @@ export function useWorkspaceTerminal({
const nextData = stripInternalTerminalCommands(data)
if (nextData) {
terminalReceivedVisibleData = true
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000)
term.write(nextData)
}
@@ -185,7 +182,6 @@ export function useWorkspaceTerminal({
}, terminalSessionId)
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
let fallbackPromptTimer: number | null = null
let sizeSyncTimer: number | null = null
let startFrame: number | null = null
let startNestedFrame: number | null = null
@@ -229,17 +225,6 @@ export function useWorkspaceTerminal({
}
void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId)
}, 150)
fallbackPromptTimer = window.setTimeout(() => {
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
if (terminalReceivedVisibleData || terminalOutputBufferRef.current[terminalSessionId]) return
const activeLine = term.buffer.active.getLine(term.buffer.active.cursorY)?.translateToString(true).trim()
if (activeLine || term.buffer.active.baseY > 0 || term.buffer.active.cursorX > 0) return
const fallbackPrompt = `${moduleName.replace(/[^a-zA-Z0-9_.-]+/g, '-')}: `
terminalReceivedVisibleData = true
terminalFallbackPromptShownRef.current.add(terminalSessionId)
terminalOutputBufferRef.current[terminalSessionId] = fallbackPrompt
term.write(fallbackPrompt)
}, 500)
})
const onResize = (): void => fitAddon.fit()
@@ -255,11 +240,9 @@ export function useWorkspaceTerminal({
removeDataListener()
removeSuppressListener()
inputDisposable.dispose()
if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer)
if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer)
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
delete terminalOutputBufferRef.current[terminalSessionId]
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
if (remoteWorkspace) window.remoteWorkspace.terminal.stop(terminalSessionId)
else window.localWorkspace.terminal.stop(terminalSessionId)
term.dispose()
@@ -65,7 +65,7 @@ type UseWorkspaceVisualizationOptions = {
platform?: string | null
threeDimensional?: boolean
savedMapDatabasePath?: string | null
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string }>
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string; savedMapWorldLayoutPath?: string }>
visualizationRuntime?: 'real' | 'simulation'
worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
@@ -255,7 +255,15 @@ export function useWorkspaceVisualization({
? bridgeTarget?.host
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork, bridgeSavedMapDatabasePath)
: await window.localWorkspace.visualization.start(
visualizationRuntimeStack,
bridgeWorkspaceRoot ?? clonePathPreview,
rosDomainId,
localBridgeProfile,
rosNetwork,
bridgeSavedMapDatabasePath,
preparedMap?.savedMapWorldLayoutPath,
)
if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running))
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'