fix: display module dialog overlays across the full viewport
This commit is contained in:
+207
-21
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user