From cb1106f3199ceb47b0c641eb5d8407a748e3de51 Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 3 Sep 2026 15:39:19 +0900 Subject: [PATCH] (fix) bug remote workspace ssh.. --- package.json | 2 +- src/main/index.ts | 75 ++++- src/preload/index.ts | 2 +- src/renderer/public/slam-viewer/index.html | 12 +- src/renderer/src/App.tsx | 274 +++++++++--------- src/renderer/src/env.d.ts | 2 +- .../src/features/platforms/platformControl.ts | 27 +- .../src/features/workspace/ModuleListView.tsx | 4 +- .../workspace/WorldMapSelectorModal.tsx | 23 +- .../workspace/useWorkspaceTerminal.ts | 17 -- .../workspace/useWorkspaceVisualization.ts | 12 +- 11 files changed, 271 insertions(+), 179 deletions(-) diff --git a/package.json b/package.json index 5916d0a..c9ea32c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/main/index.ts b/src/main/index.ts index 1157c58..969765f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,8 @@ const terminalStopTimers = new Map() const intentionalTerminalStops = new Set() const nav2RuntimeObstacleNames = new Set() const remoteSshQueues = new Map>() +const remoteGitModuleRoots = new Map() +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() const dockerStats = new Map() const installedModules = new Map() @@ -1843,6 +1855,7 @@ async function queueRemoteSsh(target: ReturnType { 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; 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 { 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)) diff --git a/src/preload/index.ts b/src/preload/index.ts index b641e7b..f5e6258 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: { diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 0772127..bb7f9a0 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -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, }; } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index dc4c727..8f9cc40 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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([]) 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 => { - 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 + 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 : {}) } } - })) ?? 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) @@ -5620,19 +5632,19 @@ export default function App(): React.JSX.Element { const exists = platforms.some((current) => current.id === connected.id) return exists ? platforms.map((current) => current.id === connected.id ? connected : current) : [...platforms, connected] }) - setSessionStartedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false })) - setSessionEndedAt(null) - setSessionStatus('running') - setStudioEntered(true) - setPlatformLoading(false) - setPlatformModalOpen(false) - setRecentSessionModalOpen(false) - setSidebarCollapsed(true) - setLocalPcLayoutOpen(true) + setSessionStartedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false })) + setSessionEndedAt(null) + setSessionStatus('running') + setStudioEntered(true) + setPlatformLoading(false) + setPlatformModalOpen(false) + setRecentSessionModalOpen(false) + setSidebarCollapsed(true) + setLocalPcLayoutOpen(true) }, 2000) - } catch { + } catch (error) { setPlatformLoading(false) - showTitlebarNotice('플랫폼 연결 정보를 불러오지 못했습니다', 'error') + showTitlebarNotice(error instanceof Error ? error.message : '플랫폼 연결 정보를 불러오지 못했습니다', 'error') setCurriculumRefreshSignal((current) => current + 1) } } diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index b0d0a6b..78af0dc 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -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 diff --git a/src/renderer/src/features/platforms/platformControl.ts b/src/renderer/src/features/platforms/platformControl.ts index 376594f..2d40a60 100644 --- a/src/renderer/src/features/platforms/platformControl.ts +++ b/src/renderer/src/features/platforms/platformControl.ts @@ -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 +} + 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 { 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 } export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise { diff --git a/src/renderer/src/features/workspace/ModuleListView.tsx b/src/renderer/src/features/workspace/ModuleListView.tsx index a9214d7..3b60688 100644 --- a/src/renderer/src/features/workspace/ModuleListView.tsx +++ b/src/renderer/src/features/workspace/ModuleListView.tsx @@ -349,7 +349,7 @@ export default function ModuleListView({ -
+