feat: add global AI guide to LMS launcher
This commit is contained in:
+188
-107
@@ -1,6 +1,8 @@
|
||||
import { app, BrowserWindow, Menu, globalShortcut, ipcMain, screen } from 'electron'
|
||||
import type { WebContents } from 'electron'
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
|
||||
import * as pty from 'node-pty'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { createWriteStream, existsSync } from 'fs'
|
||||
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
@@ -15,7 +17,7 @@ let splashWindow: BrowserWindow | null = null
|
||||
const MIN_SPLASH_VISIBLE_MS = 2000
|
||||
let splashVisibleStartedAt = 0
|
||||
let splashCloseTimer: NodeJS.Timeout | null = null
|
||||
const terminalProcesses = new Map<string, ChildProcessWithoutNullStreams>()
|
||||
const terminalProcesses = new Map<string, IPty>()
|
||||
const terminalStopTimers = new Map<string, NodeJS.Timeout>()
|
||||
const intentionalTerminalStops = new Set<string>()
|
||||
const moduleCacheRoot = resolve(homedir(), '.caches')
|
||||
@@ -45,7 +47,8 @@ const workspaceGitIgnoreLines = [
|
||||
'*.py[cod]',
|
||||
'.pytest_cache/',
|
||||
'.mypy_cache/',
|
||||
'.cache/'
|
||||
'.cache/',
|
||||
'.autonomy-studio-git.json'
|
||||
]
|
||||
const workspaceGitPathspec = [
|
||||
'--',
|
||||
@@ -489,7 +492,7 @@ function formatDockerRuntime(status: string): string {
|
||||
if (!runtime) return '실행 중'
|
||||
|
||||
const normalized = runtime.toLowerCase()
|
||||
if (normalized === 'less than a second') return '<1 sec'
|
||||
if (normalized === 'less than a second') return '1 sec'
|
||||
|
||||
return runtime
|
||||
.replace(/\babout\s+/gi, '')
|
||||
@@ -872,14 +875,16 @@ function parseGitStatus(stdout: string): GitChangedFile[] {
|
||||
const rawPath = line.slice(3).trim()
|
||||
const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ').at(-1)?.trim() ?? rawPath : rawPath
|
||||
if (isGeneratedWorkspacePath(filePath)) return null
|
||||
const statusCode = x === '?' && y === '?' ? '?' : x.trim() || y.trim() || 'M'
|
||||
const xStatus = x.trim()
|
||||
const yStatus = y.trim()
|
||||
const statusCode = xStatus === '?' && yStatus === '?' ? '?' : xStatus || yStatus || 'M'
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
status: mapGitStatusCode(statusCode),
|
||||
staged: x !== ' ' && x !== '?',
|
||||
unstaged: y !== ' ' || (x === '?' && y === '?')
|
||||
}
|
||||
return {
|
||||
path: filePath,
|
||||
status: mapGitStatusCode(statusCode),
|
||||
staged: xStatus !== '' && xStatus !== '?',
|
||||
unstaged: yStatus !== ''
|
||||
}
|
||||
})
|
||||
.filter((file): file is GitChangedFile => Boolean(file))
|
||||
}
|
||||
@@ -2354,6 +2359,43 @@ async function startWorkspaceModule(
|
||||
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
||||
}
|
||||
|
||||
async function buildWorkspaceModule(
|
||||
moduleName: string,
|
||||
workspaceRoot?: string | null,
|
||||
folderName?: string | null,
|
||||
_gpuEnabled?: boolean | null,
|
||||
rosDomainId?: string | null,
|
||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
|
||||
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
const targetFolderName = folderName?.trim()
|
||||
if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.')
|
||||
|
||||
const moduleRoot = await findWorkspaceModuleRoot(moduleName, resolvedWorkspaceRoot, targetFolderName)
|
||||
if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||
|
||||
await prepareRuntimeImages(runtimeImages)
|
||||
await runCommand(
|
||||
'docker',
|
||||
[
|
||||
'run', '--rm', '--name', `${dockerInfo.containerKey}-build`,
|
||||
'--volume', `${moduleRoot}:/autoware:rw`,
|
||||
'-u', 'root', '--net=host',
|
||||
'-e', `ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`,
|
||||
'-v', '/etc/localtime:/etc/localtime:ro',
|
||||
'--entrypoint', '/bin/bash', autowareDockerImage,
|
||||
'-lc', 'cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install'
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
900000
|
||||
)
|
||||
return { ok: true, message: '빌드가 완료되었습니다.' }
|
||||
}
|
||||
|
||||
async function startRemoteWorkspaceModule(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
moduleName: string,
|
||||
@@ -2364,7 +2406,7 @@ async function startRemoteWorkspaceModule(
|
||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const targetFolderName = folderName?.trim()
|
||||
const targetFolderName = folderName?.trim() || moduleName
|
||||
if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.')
|
||||
|
||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||
@@ -2459,21 +2501,9 @@ async function startRemoteWorkspaceModule(
|
||||
...expandRemotePathVar('ROOT'),
|
||||
`FOLDER=${quoteShellSingle(targetFolderName)}`,
|
||||
'MODULE_PATH="$ROOT/$FOLDER"',
|
||||
`BUILD_CONTAINER=${quoteShellSingle(`${dockerInfo.containerKey}-build`)}`,
|
||||
`DOCKER_IMAGE_NAME=${quoteShellSingle(autowareDockerImage)}`,
|
||||
`ROS_DOMAIN_ID_VALUE=${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')}`,
|
||||
'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true',
|
||||
'docker run --rm \\',
|
||||
' --name "$BUILD_CONTAINER" \\',
|
||||
' --volume="$MODULE_PATH:/autoware:rw" \\',
|
||||
' -u root \\',
|
||||
' --net=host \\',
|
||||
' -e ROS_DOMAIN_ID="$ROS_DOMAIN_ID_VALUE" \\',
|
||||
' -v /etc/localtime:/etc/localtime:ro \\',
|
||||
' --entrypoint /bin/bash \\',
|
||||
' "$DOCKER_IMAGE_NAME" -lc "cd /autoware && rm -rf build install log && source /opt/ros/humble/setup.bash && colcon build --symlink-install"'
|
||||
'true'
|
||||
].join('\n'),
|
||||
900000
|
||||
30000
|
||||
)
|
||||
|
||||
await runSsh(
|
||||
@@ -2494,6 +2524,42 @@ async function startRemoteWorkspaceModule(
|
||||
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
||||
}
|
||||
|
||||
async function buildRemoteWorkspaceModule(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
moduleName: string,
|
||||
folderName?: string | null,
|
||||
_gpuEnabled?: boolean | null,
|
||||
rosDomainId?: string | null,
|
||||
_runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const targetFolderName = folderName?.trim() || moduleName
|
||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||
|
||||
await runSsh(target, [
|
||||
'set -e',
|
||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||
...expandRemotePathVar('ROOT'),
|
||||
`FOLDER=${quoteShellSingle(targetFolderName)}`,
|
||||
'MODULE_PATH="$ROOT/$FOLDER"',
|
||||
'[ -d "$MODULE_PATH" ] || { echo "설치된 모듈을 찾을 수 없습니다." >&2; exit 1; }',
|
||||
`BUILD_CONTAINER=${quoteShellSingle(`${dockerInfo.containerKey}-build`)}`,
|
||||
`ROS_DOMAIN_ID_VALUE=${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')}`,
|
||||
'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true',
|
||||
'docker run --rm \\',
|
||||
' --name "$BUILD_CONTAINER" \\',
|
||||
' --volume="$MODULE_PATH:/autoware:rw" \\',
|
||||
' -u root \\',
|
||||
' --net=host \\',
|
||||
' -e ROS_DOMAIN_ID="$ROS_DOMAIN_ID_VALUE" \\',
|
||||
' -v /etc/localtime:/etc/localtime:ro \\',
|
||||
' --entrypoint /bin/bash \\',
|
||||
` ${quoteShellSingle(autowareDockerImage)} -lc "cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install"`
|
||||
].join('\n'), 900000)
|
||||
return { ok: true, message: '빌드가 완료되었습니다.' }
|
||||
}
|
||||
|
||||
async function stopWorkspaceModule(
|
||||
moduleName: string,
|
||||
workspaceRoot?: string | null,
|
||||
@@ -2510,7 +2576,12 @@ async function stopWorkspaceModule(
|
||||
|
||||
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>())
|
||||
const current = statuses.get(dockerInfo.containerKey)
|
||||
if (!current?.exists) return { ok: true, message: '중지할 컨테이너가 없습니다.' }
|
||||
const buildContainer = `${dockerInfo.containerKey}-build`
|
||||
const currentBuild = statuses.get(buildContainer)
|
||||
if (currentBuild?.exists) {
|
||||
await runCommand('docker', ['rm', '-f', buildContainer], undefined, undefined, 30000).catch(() => undefined)
|
||||
}
|
||||
if (!current?.exists) return { ok: true, message: currentBuild?.exists ? '빌드를 중지했습니다.' : '중지할 컨테이너가 없습니다.' }
|
||||
|
||||
await runCommand('docker', ['stop', dockerInfo.containerKey], undefined, undefined, 30000).catch(async (error) => {
|
||||
if (current.running) throw error
|
||||
@@ -2520,14 +2591,20 @@ async function stopWorkspaceModule(
|
||||
|
||||
async function stopRemoteWorkspaceModule(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
moduleName: string,
|
||||
folderName?: string | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const containerKey = remoteContainerKey(folderName)
|
||||
const targetFolderName = folderName?.trim() || moduleName?.trim()
|
||||
const containerKey = remoteContainerKey(targetFolderName)
|
||||
if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||
|
||||
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
|
||||
if (!currentStatus.trim()) return { ok: true, message: '중지할 컨테이너가 없습니다.' }
|
||||
const [currentStatus, buildStatus] = await Promise.all([
|
||||
runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000),
|
||||
runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}-build$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000),
|
||||
])
|
||||
if (buildStatus.trim()) await runSsh(target, `docker rm -f ${quoteShellSingle(`${containerKey}-build`)} >/dev/null 2>&1 || true`, 30000)
|
||||
if (!currentStatus.trim()) return { ok: true, message: buildStatus.trim() ? '빌드를 중지했습니다.' : '중지할 컨테이너가 없습니다.' }
|
||||
if (/^Up\b/i.test(currentStatus.trim())) {
|
||||
await runSsh(target, `docker stop ${quoteShellSingle(containerKey)}`, 30000)
|
||||
}
|
||||
@@ -2632,7 +2709,7 @@ async function restartRemoteWorkspaceModule(
|
||||
rosDomainId?: string | null,
|
||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
await stopRemoteWorkspaceModule(target, folderName)
|
||||
await stopRemoteWorkspaceModule(target, moduleName, folderName)
|
||||
return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages)
|
||||
}
|
||||
|
||||
@@ -2665,10 +2742,12 @@ async function readWorkspaceModuleLogs(
|
||||
|
||||
async function readRemoteWorkspaceModuleLogs(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
moduleName: string,
|
||||
folderName?: string | null,
|
||||
): Promise<{ ok: boolean; logs: string; message?: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const containerKey = remoteContainerKey(folderName)
|
||||
const targetFolderName = folderName?.trim() || moduleName?.trim()
|
||||
const containerKey = remoteContainerKey(targetFolderName)
|
||||
if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||
|
||||
const existsOutput = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Names}}" 2>/dev/null || true`, 20000)
|
||||
@@ -2861,7 +2940,7 @@ function stopTerminal(terminalKey: string): void {
|
||||
if (!proc) return
|
||||
intentionalTerminalStops.add(terminalKey)
|
||||
terminalProcesses.delete(terminalKey)
|
||||
if (!proc.killed) proc.kill()
|
||||
proc.kill()
|
||||
}
|
||||
|
||||
function stopTerminalsForWebContents(webContentsId: number): void {
|
||||
@@ -2885,10 +2964,19 @@ function cancelStopTerminal(terminalKey: string): void {
|
||||
terminalStopTimers.delete(terminalKey)
|
||||
}
|
||||
|
||||
function writeHiddenTerminalCommand(sender: WebContents, sessionId: string, proc: ChildProcessWithoutNullStreams, command: string): void {
|
||||
function writeHiddenTerminalCommand(sender: WebContents, sessionId: string, proc: IPty, command: string): void {
|
||||
sender.send('terminal-suppress-data', sessionId, `${command}\r\n`)
|
||||
sender.send('terminal-suppress-data', sessionId, `${command}\n`)
|
||||
proc.stdin.write(`${command}\n`)
|
||||
proc.write(`${command}\n`)
|
||||
}
|
||||
|
||||
function resizeTerminal(sender: WebContents, sessionId: string, cols?: number, rows?: number): boolean {
|
||||
const proc = terminalProcesses.get(getTerminalKey(sender.id, sessionId))
|
||||
if (!proc) return false
|
||||
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
||||
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
|
||||
proc.resize(safeCols, safeRows)
|
||||
return true
|
||||
}
|
||||
|
||||
async function resolveModuleTerminalRoot(moduleName?: string, workspaceRoot?: string | null, folderName?: string | null): Promise<string> {
|
||||
@@ -2898,6 +2986,30 @@ async function resolveModuleTerminalRoot(moduleName?: string, workspaceRoot?: st
|
||||
return moduleRoot
|
||||
}
|
||||
|
||||
function terminalEnvironment(cols: number, rows: number): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries({
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
COLUMNS: String(cols),
|
||||
LINES: String(rows)
|
||||
}).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function attachTerminalProcess(sender: WebContents, sessionId: string, terminalKey: string, proc: IPty): void {
|
||||
terminalProcesses.set(terminalKey, proc)
|
||||
proc.onData((data) => {
|
||||
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, data)
|
||||
})
|
||||
proc.onExit(({ exitCode }) => {
|
||||
terminalProcesses.delete(terminalKey)
|
||||
if (intentionalTerminalStops.delete(terminalKey)) return
|
||||
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${exitCode}]\r\n`)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2911,34 +3023,21 @@ function registerTerminalHandlers(): void {
|
||||
await mkdir(terminalCwd, { recursive: true })
|
||||
|
||||
const existingProc = terminalProcesses.get(terminalKey)
|
||||
if (existingProc && !existingProc.killed) {
|
||||
if (existingProc) {
|
||||
return true
|
||||
}
|
||||
|
||||
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 = spawn('script', ['-q', '/dev/null', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], {
|
||||
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], {
|
||||
cwd: terminalCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
COLUMNS: String(safeCols),
|
||||
LINES: String(safeRows)
|
||||
}
|
||||
name: 'xterm-256color',
|
||||
cols: safeCols,
|
||||
rows: safeRows,
|
||||
env: terminalEnvironment(safeCols, safeRows)
|
||||
})
|
||||
|
||||
terminalProcesses.set(terminalKey, proc)
|
||||
const send = (data: Buffer): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString())
|
||||
}
|
||||
proc.stdout.on('data', send)
|
||||
proc.stderr.on('data', send)
|
||||
proc.on('close', (code) => {
|
||||
terminalProcesses.delete(terminalKey)
|
||||
if (intentionalTerminalStops.delete(terminalKey)) return
|
||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`)
|
||||
})
|
||||
attachTerminalProcess(event.sender, sessionId, terminalKey, proc)
|
||||
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
||||
return true
|
||||
})
|
||||
@@ -2954,34 +3053,21 @@ function registerTerminalHandlers(): void {
|
||||
await mkdir(terminalCwd, { recursive: true })
|
||||
|
||||
const existingProc = terminalProcesses.get(terminalKey)
|
||||
if (existingProc && !existingProc.killed) {
|
||||
if (existingProc) {
|
||||
return true
|
||||
}
|
||||
|
||||
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 = spawn('script', ['-q', '/dev/null', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], {
|
||||
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], {
|
||||
cwd: terminalCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
COLUMNS: String(safeCols),
|
||||
LINES: String(safeRows)
|
||||
}
|
||||
name: 'xterm-256color',
|
||||
cols: safeCols,
|
||||
rows: safeRows,
|
||||
env: terminalEnvironment(safeCols, safeRows)
|
||||
})
|
||||
|
||||
terminalProcesses.set(terminalKey, proc)
|
||||
const send = (data: Buffer): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString())
|
||||
}
|
||||
proc.stdout.on('data', send)
|
||||
proc.stderr.on('data', send)
|
||||
proc.on('close', (code) => {
|
||||
terminalProcesses.delete(terminalKey)
|
||||
if (intentionalTerminalStops.delete(terminalKey)) return
|
||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`)
|
||||
})
|
||||
attachTerminalProcess(event.sender, sessionId, terminalKey, proc)
|
||||
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
||||
return true
|
||||
})
|
||||
@@ -2999,12 +3085,12 @@ function registerTerminalHandlers(): void {
|
||||
if (!resolved) return false
|
||||
|
||||
const existingProc = terminalProcesses.get(terminalKey)
|
||||
if (existingProc && !existingProc.killed) return true
|
||||
if (existingProc) return true
|
||||
|
||||
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 target = normalizeRemoteTarget(resolved.target)
|
||||
const proc = spawn('ssh', [
|
||||
const proc = pty.spawn('ssh', [
|
||||
'-tt',
|
||||
'-p',
|
||||
String(target.port),
|
||||
@@ -3019,45 +3105,35 @@ function registerTerminalHandlers(): void {
|
||||
`${target.user}@${target.host}`,
|
||||
buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows)
|
||||
], {
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
COLUMNS: String(safeCols),
|
||||
LINES: String(safeRows)
|
||||
}
|
||||
name: 'xterm-256color',
|
||||
cols: safeCols,
|
||||
rows: safeRows,
|
||||
env: terminalEnvironment(safeCols, safeRows)
|
||||
})
|
||||
|
||||
terminalProcesses.set(terminalKey, proc)
|
||||
const send = (data: Buffer): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString())
|
||||
}
|
||||
proc.stdout.on('data', send)
|
||||
proc.stderr.on('data', send)
|
||||
proc.on('close', (code) => {
|
||||
terminalProcesses.delete(terminalKey)
|
||||
if (intentionalTerminalStops.delete(terminalKey)) return
|
||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`)
|
||||
})
|
||||
attachTerminalProcess(event.sender, sessionId, terminalKey, proc)
|
||||
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('terminal-resize', (event, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows))
|
||||
ipcMain.handle('local-workspace:terminal:resize', (event, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows))
|
||||
ipcMain.handle('remote-workspace:terminal:resize', (event, _target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows))
|
||||
|
||||
ipcMain.on('terminal-write', (event, data: string, sessionId = 'default') => {
|
||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data)
|
||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data)
|
||||
})
|
||||
ipcMain.on('local-workspace:terminal:write', (event, data: string, sessionId = 'default') => {
|
||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data)
|
||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data)
|
||||
})
|
||||
ipcMain.on('remote-workspace:terminal:write', (event, data: string, sessionId = 'default') => {
|
||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data)
|
||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('terminal-change-directory', async (event, moduleName: string, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => {
|
||||
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
||||
cancelStopTerminal(terminalKey)
|
||||
const proc = terminalProcesses.get(terminalKey)
|
||||
if (!proc || proc.killed) return false
|
||||
if (!proc) return false
|
||||
const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => {
|
||||
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
||||
return null
|
||||
@@ -3071,7 +3147,7 @@ function registerTerminalHandlers(): void {
|
||||
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
||||
cancelStopTerminal(terminalKey)
|
||||
const proc = terminalProcesses.get(terminalKey)
|
||||
if (!proc || proc.killed) return false
|
||||
if (!proc) return false
|
||||
const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => {
|
||||
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
||||
return null
|
||||
@@ -3085,7 +3161,7 @@ function registerTerminalHandlers(): void {
|
||||
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
||||
cancelStopTerminal(terminalKey)
|
||||
const proc = terminalProcesses.get(terminalKey)
|
||||
if (!proc || proc.killed) return false
|
||||
if (!proc) return false
|
||||
const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName).catch((error) => {
|
||||
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
||||
return null
|
||||
@@ -3096,13 +3172,13 @@ function registerTerminalHandlers(): void {
|
||||
})
|
||||
|
||||
ipcMain.on('terminal-stop', (event, sessionId = 'default') => {
|
||||
scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||
stopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||
})
|
||||
ipcMain.on('local-workspace:terminal:stop', (event, sessionId = 'default') => {
|
||||
scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||
stopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||
})
|
||||
ipcMain.on('remote-workspace:terminal:stop', (event, sessionId = 'default') => {
|
||||
scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||
stopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3275,6 +3351,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||
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) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
|
||||
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
|
||||
@@ -3284,11 +3361,12 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules))
|
||||
ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||
ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, folderName))
|
||||
ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName))
|
||||
ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules))
|
||||
ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => resetRemoteWorkspaceSession(target, modules))
|
||||
ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, folderName))
|
||||
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
|
||||
ipcMain.handle('remote-workspace:git:status', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null) => readRemoteWorkspaceGitStatus(target, moduleName, folderName, selectedFilePath))
|
||||
ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId))
|
||||
ipcMain.handle('remote-workspace:git:restore-file', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreRemoteWorkspaceGitFile(target, moduleName, folderName, filePath, status))
|
||||
@@ -3400,7 +3478,7 @@ function openLearningVideoWindow(owner: BrowserWindow | null, input: LearningVid
|
||||
const url = input.url?.trim() ?? ''
|
||||
const videoId = getYoutubeVideoIdFromEmbedUrl(url)
|
||||
if (!videoId) return { ok: false, message: '지원하지 않는 동영상 URL입니다.' }
|
||||
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`
|
||||
const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`
|
||||
|
||||
const videoWindow = new BrowserWindow({
|
||||
width: 1080,
|
||||
@@ -3422,7 +3500,9 @@ function openLearningVideoWindow(owner: BrowserWindow | null, input: LearningVid
|
||||
})
|
||||
|
||||
videoWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
videoWindow.loadURL(watchUrl).catch((error) => {
|
||||
videoWindow.loadURL(embedUrl, {
|
||||
httpReferrer: 'https://workspace.autonomystudio.net/'
|
||||
}).catch((error) => {
|
||||
console.error('[learning-video] load failed', error)
|
||||
})
|
||||
|
||||
@@ -3446,6 +3526,7 @@ app.whenReady().then(() => {
|
||||
ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||
ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
|
||||
ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers())
|
||||
|
||||
Reference in New Issue
Block a user