From 36806e4dc12e523adc1170084c5db6f689fa83bc Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 22 Sep 2026 17:34:35 +0900 Subject: [PATCH] feat: add configurable VDO.Ninja overview and floating window focus --- package.json | 6 +- src/main/containerTerminal.test.ts | 20 +++++ src/main/containerTerminal.ts | 13 +++ src/main/index.ts | 28 +++++- src/preload/index.ts | 4 +- src/renderer/src/App.tsx | 19 +++- src/renderer/src/env.d.ts | 4 +- src/renderer/src/features/platforms/types.ts | 1 + .../workspace/WorkspaceCameraPanel.tsx | 88 +++++++++++++++++++ .../workspace/WorkspaceTerminalPanel.tsx | 23 ++++- .../features/workspace/WorkspaceTopBar.tsx | 9 +- .../workspace/WorkspaceVisualizationPanel.tsx | 7 ++ .../workspace/useWorkspaceTerminal.ts | 46 +++++++++- .../workspace/useWorkspaceWindowFocus.ts | 52 +++++++++++ 14 files changed, 304 insertions(+), 16 deletions(-) create mode 100644 src/main/containerTerminal.test.ts create mode 100644 src/main/containerTerminal.ts create mode 100644 src/renderer/src/features/workspace/WorkspaceCameraPanel.tsx create mode 100644 src/renderer/src/features/workspace/useWorkspaceWindowFocus.ts diff --git a/package.json b/package.json index c7b4726..fb0c969 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.29", + "version": "1.1.30", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { @@ -18,6 +18,10 @@ "directories": { "output": "dist" }, + "files": [ + "out/**/*", + "package.json" + ], "extraResources": [ { "from": "resources/icon.png", diff --git a/src/main/containerTerminal.test.ts b/src/main/containerTerminal.test.ts new file mode 100644 index 0000000..c6f4bf0 --- /dev/null +++ b/src/main/containerTerminal.test.ts @@ -0,0 +1,20 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { resolveContainerTerminal } from './containerTerminal' + +const inspection = (running = true) => JSON.stringify([{ + Id: 'container-id', State: { Running: running }, Mounts: [ + { Source: '/home/user/ws', Destination: '/workspace' }, + { Source: '/home/user/ws/module', Destination: '/module' } + ] +}]) + +test('resolves module path from the most specific actual mount', () => { + assert.deepEqual(resolveContainerTerminal(inspection(), '/home/user/ws/module'), { id: 'container-id', cwd: '/module' }) + assert.equal(resolveContainerTerminal(inspection(), '/home/user/ws/other').cwd, '/workspace/other') +}) + +test('rejects stopped containers and unrelated workspace paths', () => { + assert.throws(() => resolveContainerTerminal(inspection(false), '/home/user/ws/module'), /not running/) + assert.throws(() => resolveContainerTerminal(inspection(), '/home/user/ws-other/module'), /not mounted/) +}) diff --git a/src/main/containerTerminal.ts b/src/main/containerTerminal.ts new file mode 100644 index 0000000..7e1bfa7 --- /dev/null +++ b/src/main/containerTerminal.ts @@ -0,0 +1,13 @@ +import { posix } from 'node:path' + +export function resolveContainerTerminal(inspectJson: string, moduleRoot: string): { id: string; cwd: string } { + const [container] = JSON.parse(inspectJson) + if (!container?.State?.Running) throw new Error('Module container is not running') + const root = posix.resolve(moduleRoot) + const mounts = (container.Mounts ?? []) as { Source: string; Destination: string }[] + const mount = mounts + .filter((item) => root === item.Source || root.startsWith(`${item.Source.replace(/\/$/, '')}/`)) + .sort((a, b) => b.Source.length - a.Source.length)[0] + if (!mount) throw new Error('Module workspace is not mounted in this container') + return { id: container.Id, cwd: posix.join(mount.Destination, posix.relative(mount.Source, root)) } +} diff --git a/src/main/index.ts b/src/main/index.ts index b009c99..a5150af 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { checkDockerStatus } from './dockerStatus' +import { resolveContainerTerminal } from './containerTerminal' import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime' import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress' import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress' @@ -5964,17 +5965,31 @@ function terminalEnvironment(cols: number, rows: number): Record } function attachTerminalProcess(sender: WebContents, sessionId: string, terminalKey: string, proc: IPty): void { + intentionalTerminalStops.delete(terminalKey) terminalProcesses.set(terminalKey, proc) proc.onData((data) => { if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, data) }) proc.onExit(({ exitCode }) => { + if (terminalProcesses.get(terminalKey) !== proc) return terminalProcesses.delete(terminalKey) if (intentionalTerminalStops.delete(terminalKey)) return if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${exitCode}]\r\n`) }) } +async function containerTerminalCommand(moduleRoot: string, moduleName: string | undefined, cols: number, rows: number, target?: ReturnType): Promise { + const info = parseModuleDockerInfo(basename(moduleRoot)) + if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('Invalid module container name') + const output = target + ? await runSshOutput(target, `docker inspect ${quoteShellSingle(info.containerKey)}`, 10000) + : await runCommandOutput('docker', ['inspect', info.containerKey], undefined, undefined, 10000) + const container = resolveContainerTerminal(output, moduleRoot) + const shell = buildWorkspaceShellCommand(moduleName, container.cwd, cols, rows, false) + const init = 'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi; if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi; exec ' + shell + return `exec docker exec -it -e TERM=xterm-256color -w ${quoteShellSingle(container.cwd)} ${quoteShellSingle(container.id)} /bin/bash --noprofile --norc -c ${quoteShellSingle(init)}` +} + function registerTerminalHandlers(): void { ipcMain.handle('terminal-start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { const webContentsId = event.sender.id @@ -6006,7 +6021,7 @@ function registerTerminalHandlers(): void { event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId)) return true }) - ipcMain.handle('local-workspace:terminal:start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => { + ipcMain.handle('local-workspace:terminal:start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null, mode: 'host' | 'container' = 'host') => { const webContentsId = event.sender.id const terminalKey = getTerminalKey(webContentsId, sessionId) cancelStopTerminal(terminalKey) @@ -6024,7 +6039,10 @@ function registerTerminalHandlers(): void { const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) - const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], { + const command = mode === 'container' + ? await containerTerminalCommand(terminalCwd, moduleName, safeCols, safeRows) + : buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false) + const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', command], { cwd: terminalCwd, name: 'xterm-256color', cols: safeCols, @@ -6036,7 +6054,7 @@ function registerTerminalHandlers(): void { event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId)) return true }) - ipcMain.handle('remote-workspace:terminal:start', async (event, targetInput: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', folderName?: string | null) => { + ipcMain.handle('remote-workspace:terminal:start', async (event, targetInput: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', folderName?: string | null, mode: 'host' | 'container' = 'host') => { const webContentsId = event.sender.id const terminalKey = getTerminalKey(webContentsId, sessionId) cancelStopTerminal(terminalKey) @@ -6068,7 +6086,9 @@ function registerTerminalHandlers(): void { '-i', target.identityFile, `${target.user}@${target.host}`, - buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true) + mode === 'container' + ? await containerTerminalCommand(resolved.moduleRoot, moduleName, safeCols, safeRows, target) + : buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true) ], { name: 'xterm-256color', cols: safeCols, diff --git a/src/preload/index.ts b/src/preload/index.ts index f2c4337..56d7e46 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -265,7 +265,7 @@ contextBridge.exposeInMainWorld('localWorkspace', { commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash) }, terminal: { - start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName), + start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string, mode?: 'host' | 'container') => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName, mode), resize: (cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('local-workspace:terminal:resize', cols, rows, sessionId), changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:change-directory', moduleName, sessionId, workspaceRoot, folderName), write: (data: string, sessionId?: string) => ipcRenderer.send('local-workspace:terminal:write', data, sessionId), @@ -336,7 +336,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', { commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash) }, terminal: { - start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName), + start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string, mode?: 'host' | 'container') => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName, mode), resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('remote-workspace:terminal:resize', target, cols, rows, sessionId), changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:change-directory', target, moduleName, sessionId, folderName), write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 94dc7de..344ad02 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -81,6 +81,7 @@ import type { DiffViewLine, GitChangedFile } from './features/workspace/gitUtils import WorkspaceModuleTabs from './features/workspace/WorkspaceModuleTabs' import WorkspaceNav, { type WorkspaceActivity } from './features/workspace/WorkspaceNav' import WorkspaceTopBar from './features/workspace/WorkspaceTopBar' +import WorkspaceCameraPanel from './features/workspace/WorkspaceCameraPanel' import WorkspaceVisualizationPanel from './features/workspace/WorkspaceVisualizationPanel' import { GitFileContextMenu, PowerActionModal, SourceSettingsSaveConfirmModal, SubmitConfirmModal, TreeTargetDialog, UnsavedEditorCloseModal } from './features/workspace/WorkspaceModals' @@ -520,6 +521,8 @@ function LocalPcWorkspaceMockup({ }): React.JSX.Element { const visualizationHostInstanceId = useRef(`${platformId}:${Math.random().toString(36).slice(2)}`).current const [powerActionModalOpen, setPowerActionModalOpen] = useState(false) + const [cameraOpen, setCameraOpen] = useState(false) + useEffect(() => { setCameraOpen(false) }, [platformId, workspaceVisible]) const [controlCountdownNow, setControlCountdownNow] = useState(Date.now()) const { qaMode, @@ -2112,11 +2115,15 @@ function LocalPcWorkspaceMockup({ copyTerminalSelection, pasteTerminalClipboard, startTerminalResize, - stopAllTerminalSessions + stopAllTerminalSessions, + terminalMode, + setTerminalMode, + containerAvailable } = useWorkspaceTerminal({ moduleName: selectedModule?.name, moduleFolderName: selectedModule?.folderName, openModuleNames: moduleTabs.map((module) => module.name), + runningModuleNames: availableModules.filter((module) => module.state === 'running').map((module) => module.name), clonePathPreview, remoteWorkspace, getRemoteWorkspaceTarget, @@ -3416,7 +3423,14 @@ function LocalPcWorkspaceMockup({ return (
+ {cameraOpen && workspaceVisible && platform.id === platformId)?.camera} + dark={dark} + onClose={() => setCameraOpen(false)} + />} setCameraOpen((open) => !open)} dark={dark} textStrong={textStrong} textMid={textMid} @@ -3924,6 +3938,9 @@ function LocalPcWorkspaceMockup({ {!exampleActive && ( } terminal: { - start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise + start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string, mode?: 'host' | 'container') => Promise resize: (cols?: number, rows?: number, sessionId?: string) => Promise changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise write: (data: string, sessionId?: string) => void @@ -369,7 +369,7 @@ interface Window { commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType } terminal: { - start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise + start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string, mode?: 'host' | 'container') => Promise resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise write: (data: string, sessionId?: string) => void diff --git a/src/renderer/src/features/platforms/types.ts b/src/renderer/src/features/platforms/types.ts index ee81d79..672e48c 100644 --- a/src/renderer/src/features/platforms/types.ts +++ b/src/renderer/src/features/platforms/types.ts @@ -34,6 +34,7 @@ export type VehicleRuntimeProfile = { } export type PlatformOption = { + camera?: { url: string; password: string } | null id: string platformKey: string title: string diff --git a/src/renderer/src/features/workspace/WorkspaceCameraPanel.tsx b/src/renderer/src/features/workspace/WorkspaceCameraPanel.tsx new file mode 100644 index 0000000..84fb914 --- /dev/null +++ b/src/renderer/src/features/workspace/WorkspaceCameraPanel.tsx @@ -0,0 +1,88 @@ +import { Camera, CirclePlay, Maximize2, Minimize2, Square, X } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useWorkspaceWindowFocus } from './useWorkspaceWindowFocus' + +export function cameraViewerUrl(camera: { url: string; password: string }): string { + const input = new URL(camera.url) + if (input.protocol !== 'https:' || input.hostname !== 'vdo.ninja' || input.port || input.username || input.password || input.pathname !== '/') throw new Error('카메라 시청 URL이 올바르지 않습니다.') + const room = input.searchParams.get('room') + const view = input.searchParams.get('view') + if (!room?.trim() || !view?.trim()) throw new Error('카메라 시청 URL에 room과 view가 필요합니다.') + const url = new URL('https://vdo.ninja/') + url.searchParams.set('room', room) + url.searchParams.set('view', view) + url.searchParams.set('scene', '') + if (camera.password) url.searchParams.set('password', camera.password) + return url.toString() +} + +export default function WorkspaceCameraPanel({ camera, dark, onClose }: { + camera?: { url: string; password: string } | null + dark: boolean + onClose: () => void +}): React.JSX.Element { + const [viewerUrl, setViewerUrl] = useState('') + const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('overview') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [large, setLarge] = useState(false) + const [position, setPosition] = useState({ x: 32, y: 90 }) + const panelRef = useRef(null) + const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null) + useEffect(() => { + setViewerUrl('') + setLoading(false) + setError('') + }, [camera?.url, camera?.password]) + const start = (): void => { + try { + if (!camera?.url) throw new Error('등록된 주행장 카메라가 없습니다.') + setViewerUrl(cameraViewerUrl(camera)) + setLoading(true) + setError('') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '카메라 연결에 실패했습니다.') + } + } + const buttonClass = 'grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded hover:bg-gray-500/20' + return createPortal( +
+
{ + if (large || (event.target as HTMLElement).closest('button')) return + drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y } + event.currentTarget.setPointerCapture(event.pointerId) + }} + onPointerMove={(event) => { + if (!drag.current) return + const width = panelRef.current?.offsetWidth ?? 660 + const height = panelRef.current?.offsetHeight ?? 420 + setPosition({ x: Math.max(0, Math.min(window.innerWidth - width, drag.current.left + event.clientX - drag.current.x)), y: Math.max(0, Math.min(window.innerHeight - height, drag.current.top + event.clientY - drag.current.y)) }) + }} + onPointerUp={() => { drag.current = null }} onLostPointerCapture={() => { drag.current = null }}> + 오버뷰 + {viewerUrl && } + + +
+
+ {viewerUrl ? <> + {loading &&
영상 연결 중...
} +