fix: display module dialog overlays across the full viewport

This commit is contained in:
2026-09-08 14:11:59 +09:00
parent bfb3cbaf9b
commit 5aef38a99d
7 changed files with 507 additions and 39 deletions
+207 -21
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, Menu, globalShortcut, ipcMain, screen } from 'electron'
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen } from 'electron'
import type { WebContents } from 'electron'
import { spawn } from 'child_process'
import * as pty from 'node-pty'
@@ -204,6 +204,17 @@ type CustomModuleScaffoldInput = {
launchFile: string
}
type CustomModuleImportSource = {
sourcePath: string
packageName: string
launchFiles: string[]
buildType: 'ament_python' | 'ament_cmake'
}
type CustomModuleImportInput = CustomModuleScaffoldInput & {
sourcePath: string
}
type ModulePrebuiltArtifact = {
type?: string | null
url?: string | null
@@ -931,19 +942,152 @@ function validateCustomModuleScaffold(input: CustomModuleScaffoldInput): CustomM
return { name, category, folderName, packageName, launchFile, dockerImage }
}
function customModuleConfigContent(input: CustomModuleScaffoldInput): string {
const module = validateCustomModuleScaffold(input)
return `${JSON.stringify({
sourceType: 'CUSTOM',
name: module.name,
category: module.category,
dockerImage: module.dockerImage,
packageName: module.packageName,
launchFile: module.launchFile,
}, null, 2)}\n`
}
async function resolveCustomModuleImportSource(sourcePath: string): Promise<CustomModuleImportSource> {
const selectedPath = sourcePath.trim()
if (!selectedPath) throw new Error('불러올 모듈 폴더를 선택하세요.')
const selectedRoot = resolve(selectedPath)
let packageRoot = selectedRoot
try {
if (!(await stat(selectedRoot)).isDirectory()) throw new Error('not-a-directory')
} catch (error) {
if (error instanceof Error && error.message === 'not-a-directory') {
throw new Error('선택한 경로가 폴더가 아닙니다.')
}
throw new Error('선택한 모듈 폴더를 찾을 수 없습니다.')
}
if (!existsSync(join(packageRoot, 'package.xml'))) {
const sourceDirectory = join(selectedRoot, 'src')
let packageDirectories: string[] = []
try {
packageDirectories = (await readdir(sourceDirectory, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && existsSync(join(sourceDirectory, entry.name, 'package.xml')))
.map((entry) => join(sourceDirectory, entry.name))
} catch {
// The error below explains the required ROS workspace structure.
}
if (packageDirectories.length === 0) {
throw new Error('package.xml을 찾을 수 없습니다. ROS2 패키지 폴더 또는 src 아래에 패키지가 하나 있는 워크스페이스를 선택하세요.')
}
if (packageDirectories.length > 1) {
throw new Error('여러 ROS2 패키지가 있습니다. 불러올 패키지 폴더를 직접 선택하세요.')
}
packageRoot = packageDirectories[0]
}
const packageXml = await readFile(join(packageRoot, 'package.xml'), 'utf8')
const packageName = packageXml.match(/<name>\s*([^<]+?)\s*<\/name>/)?.[1]?.trim() ?? ''
if (!/^[a-z][a-z0-9_]*$/.test(packageName)) {
throw new Error('package.xml의 패키지명이 ROS2 패키지명 형식과 맞지 않습니다.')
}
const buildType = /<buildtool_depend>\s*ament_python\s*<\/buildtool_depend>/.test(packageXml)
? 'ament_python'
: /<buildtool_depend>\s*ament_cmake\s*<\/buildtool_depend>/.test(packageXml)
? 'ament_cmake'
: null
if (!buildType) throw new Error('ament_python 또는 ament_cmake 기반 ROS2 패키지만 불러올 수 있습니다.')
if (buildType === 'ament_python' && !existsSync(join(packageRoot, 'setup.py'))) {
throw new Error('ament_python 패키지에 setup.py가 없습니다.')
}
if (buildType === 'ament_cmake' && !existsSync(join(packageRoot, 'CMakeLists.txt'))) {
throw new Error('ament_cmake 패키지에 CMakeLists.txt가 없습니다.')
}
let launchFiles: string[] = []
try {
launchFiles = (await readdir(join(packageRoot, 'launch'), { withFileTypes: true }))
.filter((entry) => entry.isFile() && /^[a-zA-Z0-9_.-]+\.launch\.py$/.test(entry.name))
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right))
} catch {
// The validation message below covers a missing launch directory too.
}
if (launchFiles.length === 0) {
throw new Error('launch 폴더에 실행 가능한 *.launch.py 파일이 없습니다.')
}
return { sourcePath: packageRoot, packageName, launchFiles, buildType }
}
async function selectCustomModuleImportSource(): Promise<CustomModuleImportSource | null> {
const selection = await dialog.showOpenDialog({
title: '모듈 불러오기',
buttonLabel: '선택',
properties: ['openDirectory'],
})
if (selection.canceled || !selection.filePaths[0]) return null
return resolveCustomModuleImportSource(selection.filePaths[0])
}
const ignoredCustomModuleImportDirectories = new Set([
'.git',
'.mypy_cache',
'.pytest_cache',
'__pycache__',
'build',
'install',
'log',
'node_modules',
])
async function importCustomWorkspaceModule(
workspaceRoot: string | null | undefined,
input: CustomModuleImportInput,
): Promise<{ ok: boolean; workspacePath: string }> {
const module = validateCustomModuleScaffold(input)
const source = await resolveCustomModuleImportSource(input.sourcePath)
if (source.packageName !== module.packageName || !source.launchFiles.includes(module.launchFile)) {
throw new Error('선택한 소스의 패키지 또는 Launch 파일 검증 결과가 현재 입력값과 다릅니다. 다시 선택하세요.')
}
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
const moduleRoot = resolve(resolvedWorkspaceRoot, module.folderName)
if (moduleRoot === resolvedWorkspaceRoot || !moduleRoot.startsWith(`${resolvedWorkspaceRoot}/`)) {
throw new Error('허용되지 않은 모듈 경로입니다.')
}
if (existsSync(moduleRoot)) throw new Error('같은 이름의 모듈이 이미 현재 워크스페이스에 있습니다.')
await mkdir(resolvedWorkspaceRoot, { recursive: true })
try {
await cp(source.sourcePath, moduleRoot, {
recursive: true,
errorOnExist: true,
force: false,
filter: (currentPath) => {
const pathParts = relative(source.sourcePath, currentPath).split(/[\\/]/).filter(Boolean)
return !pathParts.some((part) => ignoredCustomModuleImportDirectories.has(part))
},
})
await writeFile(resolve(moduleRoot, '.autonomy-studio-module.json'), customModuleConfigContent(module), { encoding: 'utf8', flag: 'wx' })
await runCommand('git', ['init', '--initial-branch=main'], moduleRoot, undefined, 15_000).catch(() => undefined)
} catch (error) {
await rm(moduleRoot, { recursive: true, force: true }).catch(() => undefined)
throw error
}
return { ok: true, workspacePath: moduleRoot }
}
function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record<string, string> {
const module = validateCustomModuleScaffold(input)
const executable = `${module.packageName}_node`
const nodePath = `${module.packageName}/node.py`
return {
'.autonomy-studio-module.json': `${JSON.stringify({
sourceType: 'CUSTOM',
name: module.name,
category: module.category,
dockerImage: module.dockerImage,
packageName: module.packageName,
launchFile: module.launchFile,
}, null, 2)}\n`,
'.autonomy-studio-module.json': customModuleConfigContent(module),
'.gitignore': 'build/\ninstall/\nlog/\n__pycache__/\n*.py[cod]\n',
'package.xml': [
'<?xml version="1.0"?>',
@@ -4058,6 +4202,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
discoveryBasePort,
...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId))
]
const localAddresses = Array.from(new Set(['127.0.0.1', localAddress]))
const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress]))
return [
'<?xml version="1.0" encoding="UTF-8" ?>',
@@ -4070,8 +4215,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
' <receiveBufferSize>1048576</receiveBufferSize>',
' <maxMessageSize>1200</maxMessageSize>',
' <interfaceWhiteList>',
' <address>127.0.0.1</address>',
` <address>${localAddress}</address>`,
...localAddresses.map((address) => ` <address>${address}</address>`),
' </interfaceWhiteList>',
' </transport_descriptor>',
' </transport_descriptors>',
@@ -4083,12 +4227,14 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
' </userTransports>',
' <builtin>',
' <metatrafficUnicastLocatorList>',
...localAddresses.flatMap((address) => [
' <locator>',
' <udpv4>',
` <address>${localAddress}</address>`,
` <address>${address}</address>`,
' <port>0</port>',
' </udpv4>',
' </locator>',
]),
' </metatrafficUnicastLocatorList>',
' <initialPeersList>',
...peerAddresses.flatMap((address) => peers.flatMap((port) => [
@@ -4269,9 +4415,29 @@ function buildNav2DockerRunScript(): string {
'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}',
'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}',
'LOCALIZATION_MODE=slam',
'WORLD_LAYOUT_PATH_VALUE=/modules/world-layout.json',
'filtered_args=()',
'for arg in "$@"; do',
' if [ "$arg" = "localization_mode:=amcl" ]; then LOCALIZATION_MODE=amcl; else filtered_args+=("$arg"); fi',
' case "$arg" in',
' localization_mode:=amcl)',
' LOCALIZATION_MODE=amcl',
' ;;',
' world_layout_path:=*)',
' candidate_path=${arg#world_layout_path:=}',
' case "$candidate_path" in',
' /modules/maps/*.gazebo-layout.json|/modules/maps/*.gazebo-3d-layout.json)',
' WORLD_LAYOUT_PATH_VALUE=$candidate_path',
' ;;',
' *)',
' echo "Invalid saved map world layout path: $candidate_path" >&2',
' exit 64',
' ;;',
' esac',
' ;;',
' *)',
' filtered_args+=("$arg")',
' ;;',
' esac',
'done',
'set -- "${filtered_args[@]}"',
'',
@@ -4332,6 +4498,7 @@ function buildNav2DockerRunScript(): string {
' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"',
' -e "MODULE_ROOT=/modules"',
' -e "MODULE_FOLDER=$MODULE_FOLDER"',
' -e "WORLD_LAYOUT_PATH=$WORLD_LAYOUT_PATH_VALUE"',
' -e "GAZEBO_MODEL_PATH=/usr/share/gazebo-11/models:/modules/[lms-nav2-simulator]nav2-simulator_v1.0.0_ws/install/turtlebot3_gazebo/share/turtlebot3_gazebo/models:/modules/[lms-nav2-gazebo-simulator]nav2-simulator_v1.0.0_ws/install/turtlebot3_gazebo/share/turtlebot3_gazebo/models"',
' -v "$MODULE_ROOT:/modules"',
' -v "$(dirname "$0"):/runtime/nav2/docker:ro"',
@@ -5514,8 +5681,24 @@ function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
function buildWorkspaceShellCommand(moduleName: string | undefined, terminalCwd: string, cols: number, rows: number): string {
function buildWorkspaceShellCommand(
moduleName: string | undefined,
terminalCwd: string,
cols: number,
rows: number,
restrictToModuleWorkspace: boolean,
): string {
const promptLabel = (moduleName?.trim() || basename(terminalCwd)).replace(/[^a-zA-Z0-9_.-]+/g, '-')
const navigationCommands = restrictToModuleWorkspace
? [
'__module_workspace_guard() { if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" || "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then return 0; fi; builtin cd "$MODULE_WORKSPACE_ROOT"; printf "\\n모듈 워크스페이스 밖으로 이동할 수 없습니다.\\n"; return 1; }',
'cd() { builtin cd "$@" || return; __module_workspace_guard; }',
'export PROMPT_COMMAND=\'__module_workspace_guard; if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; else __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'',
'export -f __module_workspace_guard cd',
]
: [
'export PROMPT_COMMAND=\'if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; elif [[ "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; else __rel=".../${PWD##*/}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'',
]
const initCommands = [
`stty cols ${cols} rows ${rows}`,
@@ -5526,10 +5709,8 @@ function buildWorkspaceShellCommand(moduleName: string | undefined, terminalCwd:
'grep() { command grep --color=auto "$@"; }',
'egrep() { command egrep --color=auto "$@"; }',
'fgrep() { command fgrep --color=auto "$@"; }',
'__module_workspace_guard() { if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" || "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then return 0; fi; builtin cd "$MODULE_WORKSPACE_ROOT"; printf "\\n모듈 워크스페이스 밖으로 이동할 수 없습니다.\\n"; return 1; }',
'cd() { builtin cd "$@" || return; __module_workspace_guard; }',
'export PROMPT_COMMAND=\'__module_workspace_guard; if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; else __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'',
'export -f ls grep egrep fgrep __module_workspace_guard cd',
'export -f ls grep egrep fgrep',
...navigationCommands,
'exec /bin/bash --noprofile --norc -i'
]
@@ -5627,7 +5808,7 @@ 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)], {
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], {
cwd: terminalCwd,
name: 'xterm-256color',
cols: safeCols,
@@ -5657,7 +5838,7 @@ 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)], {
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], {
cwd: terminalCwd,
name: 'xterm-256color',
cols: safeCols,
@@ -5701,7 +5882,7 @@ function registerTerminalHandlers(): void {
'-i',
target.identityFile,
`${target.user}@${target.host}`,
buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows)
buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true)
], {
name: 'xterm-256color',
cols: safeCols,
@@ -5970,6 +6151,11 @@ function registerLocalWorkspaceHandlers(): void {
if (!input) throw new Error('커스텀 모듈 정보가 없습니다.')
return createCustomWorkspaceModule(workspaceRoot, input)
})
ipcMain.handle('local-workspace:modules:select-import-source', () => selectCustomModuleImportSource())
ipcMain.handle('local-workspace:modules:import-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleImportInput | null) => {
if (!input) throw new Error('불러올 모듈 정보가 없습니다.')
return importCustomWorkspaceModule(workspaceRoot, input)
})
ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams))