This commit is contained in:
2026-08-03 13:46:47 +09:00
parent 4841a699b1
commit ffd6ae9437
10 changed files with 663 additions and 82 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "autonomy-studio-launcher",
"version": "1.0.29",
"version": "1.1.0",
"description": "Autonomy Studio v2 Frontend",
"main": "./out/main/index.js",
"author": {
+72 -9
View File
@@ -404,18 +404,41 @@ function createCleanChildEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return env
}
function terminateChildProcess(child: ChildProcess): void {
if (child.killed) return
const kill = (signal: NodeJS.Signals): boolean => {
if (process.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid, signal)
return true
} catch {
// Fall back to the direct child when a process group is unavailable.
}
}
child.kill(signal)
return false
}
const killedProcessGroup = kill('SIGTERM')
setTimeout(() => {
if (killedProcessGroup && process.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid, 'SIGKILL')
return
} catch {
// The group already exited.
}
}
if (!child.killed) child.kill('SIGKILL')
}, 1500).unref()
}
function stopChildApps(
appProcesses: Map<string, ChildProcess>,
appWindows: Map<string, BrowserWindow>,
): void {
for (const child of appProcesses.values()) {
if (!child.killed) {
child.kill('SIGTERM')
setTimeout(() => {
if (!child.killed) child.kill('SIGKILL')
}, 1500).unref()
}
}
for (const child of appProcesses.values()) terminateChildProcess(child)
appProcesses.clear()
for (const win of appWindows.values()) {
@@ -749,6 +772,23 @@ async function logoutTailscale(): Promise<CommandResult> {
}
}
async function restartTailscaleService(): Promise<CommandResult> {
try {
await runCommand('pkexec', ['/usr/bin/systemctl', 'restart', 'tailscaled'], 60000)
await delay(1000)
const status = await readTailscaleStatus()
return {
ok: true,
message: status.connected ? '원격 연결 서비스가 재시작되었습니다.' : '원격 연결 서비스가 재시작되었습니다. 연결 상태를 다시 확인하세요.',
}
} catch (error) {
return {
ok: false,
message: commandErrorMessage(error, '원격 연결 서비스 재시작 실패'),
}
}
}
function createSplashWindow(): void {
splash = new BrowserWindow({
width: 580,
@@ -799,7 +839,9 @@ function createWindow(): void {
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
sandbox: false,
// Keep token refresh and launcher background work running while child apps are open.
backgroundThrottling: false
}
})
attachDevToolsShortcut(mainWindow)
@@ -815,6 +857,10 @@ function createWindow(): void {
}
}, 1500)
})
mainWindow.on('close', () => {
lmsWebBridge.invalidateSessions()
stopChildApps(appProcesses, appWindows)
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
@@ -988,6 +1034,7 @@ function createWindow(): void {
loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)),
)
ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale())
ipcMain.handle('launcher-tailscale-restart-service', () => restartTailscaleService())
const onAppClosed = (appName: string): void => {
mainWindow.webContents.send('app-window-closed', appName)
@@ -997,6 +1044,22 @@ function createWindow(): void {
}
}
ipcMain.handle('launcher-end-user-session', () => {
lmsWebBridge.invalidateSessions()
for (const [appName, win] of appWindows) {
if (appName.trim().toLowerCase() !== 'lms') continue
if (!win.isDestroyed()) win.close()
}
for (const [appName, child] of appProcesses) {
if (appName.trim().toLowerCase() !== 'lms' || child.killed) continue
terminateChildProcess(child)
}
return { ok: true }
})
ipcMain.on('launch-app', (_, payload: {
appName: string
url?: string
+344 -19
View File
@@ -14,6 +14,8 @@ type CourseModule = {
folderName: string
category?: string
sortOrder?: number
defaultParameters?: string[] | null
dockerImage?: string | null
templateRepositoryUrl?: string | null
studentRepositoryUrl?: string | null
}
@@ -47,10 +49,26 @@ type RuntimeImageArtifact = {
required?: boolean | null
}
type WorldLayoutObject = {
id: string
type: 'pillar' | 'wall' | 'box'
position: [number, number, number]
rotation: [number, number, number]
size: [number, number, number]
}
type WorldLayout = {
version: 1
objects: WorldLayoutObject[]
}
const BRIDGE_SESSION_TTL_MS = 30 * 60 * 1000
const DEFAULT_WORKSPACE_ROOT = '~/autonomy-studio/workspaces'
const LAUNCHER_SSH_IDENTITY_FILE = resolve(homedir(), '.autonomy-studio', 'ssh', 'id_ed25519')
const AUTOWARE_IMAGE = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda'
const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|ya?ml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i
const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|ya?ml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i
const imageFilePattern = /\.(png|jpe?g|webp|gif|svg)$/i
const maxImagePreviewBytes = 10 * 1024 * 1024
const skippedDirectoryNames = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker'])
const studioGitMetadataFile = '.autonomy-studio-git.json'
const workspaceGitIgnoreLines = [
@@ -114,6 +132,39 @@ function expandWorkspaceRoot(value?: string): string {
return path
}
function defaultWorldLayout(): WorldLayout {
return { version: 1, objects: [] }
}
function normalizeWorldLayout(value: unknown): WorldLayout {
if (!value || typeof value !== 'object' || Array.isArray(value)) return defaultWorldLayout()
const objects = Array.isArray((value as { objects?: unknown }).objects) ? (value as { objects: unknown[] }).objects : []
return {
version: 1,
objects: objects.flatMap((item): WorldLayoutObject[] => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return []
const candidate = item as Partial<WorldLayoutObject>
if (!candidate.id || !['pillar', 'wall', 'box'].includes(candidate.type ?? '')) return []
const vector = (input: unknown, fallback: [number, number, number]): [number, number, number] => (
Array.isArray(input) && input.length === 3 && input.every((entry) => typeof entry === 'number' && Number.isFinite(entry))
? [input[0] as number, input[1] as number, input[2] as number]
: fallback
)
return [{
id: String(candidate.id).slice(0, 120),
type: candidate.type as WorldLayoutObject['type'],
position: vector(candidate.position, [0, 0, 0]),
rotation: vector(candidate.rotation, [0, 0, 0]),
size: vector(candidate.size, [1, 1, 1]),
}]
}),
}
}
function worldLayoutPath(workspaceRoot?: string): string {
return resolve(expandWorkspaceRoot(workspaceRoot), 'world-layout.json')
}
function validFolderName(value: unknown): value is string {
return typeof value === 'string' && /^\[[A-Za-z0-9_.-]+\][A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+_ws$/.test(value)
}
@@ -125,6 +176,23 @@ function containerName(folderName: string): string | null {
return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(name) ? name : null
}
function nav2LaunchSpec(folderName: string): { packageName: string; launchFile: string } | null {
const module = folderName.match(/^\[lms-nav2-(?:gazebo-)?([a-z]+)\]/)?.[1]
const specs: Record<string, { packageName: string; launchFile: string }> = {
simulator: { packageName: 'nav2_turtlebot_simulator', launchFile: 'simulator.launch.py' },
vehicle: { packageName: 'nav2_turtlebot_vehicle', launchFile: 'vehicle.launch.py' },
mkmini: { packageName: 'vehicle_pkg', launchFile: 'vehicle.launch.py' },
sensing: { packageName: 'nav2_turtlebot_sensing', launchFile: 'sensing.launch.py' },
map: { packageName: 'nav2_turtlebot_map', launchFile: 'map_server.launch.py' },
slam: { packageName: 'slam_pkg', launchFile: 'slam.launch.py' },
localization: { packageName: 'nav2_turtlebot_localization', launchFile: 'slam.launch.py' },
planning: { packageName: 'nav2_turtlebot_planning', launchFile: 'planning.launch.py' },
control: { packageName: 'nav2_turtlebot_control', launchFile: 'control.launch.py' },
system: { packageName: 'nav2_turtlebot_system', launchFile: 'system.launch.py' },
}
return module ? specs[module] ?? null : null
}
function validRepositoryUrl(value: unknown): value is string {
if (typeof value !== 'string' || !value.trim()) return false
return /^(https?:\/\/|ssh:\/\/|git@)[A-Za-z0-9_.:@/~-]+(?:\.git)?$/i.test(value.trim())
@@ -251,9 +319,11 @@ function buildRemoteWorkspaceShellCommand(moduleName: string | undefined, remote
const promptLabel = (moduleName?.trim() || basename(remotePath)).replace(/[^a-zA-Z0-9_.-]+/g, '-')
const safeCols = terminalSize(cols, 80, 20, 300)
const safeRows = terminalSize(rows, 24, 5, 120)
const workspaceRootAssignment = remotePath.startsWith('~/')
? `MODULE_WORKSPACE_ROOT="$HOME/${remotePath.slice(2).replace(/"/g, '\\"')}"`
: `MODULE_WORKSPACE_ROOT=${quoteShell(remotePath)}`
const initCommands = [
`MODULE_WORKSPACE_ROOT=${quoteShell(remotePath)}`,
'case "$MODULE_WORKSPACE_ROOT" in ~/*) MODULE_WORKSPACE_ROOT="$HOME/${MODULE_WORKSPACE_ROOT#~/}";; esac',
workspaceRootAssignment,
`export MODULE_PROMPT_LABEL=${quoteShell(promptLabel)}`,
`stty cols ${safeCols} rows ${safeRows}`,
'cd "$MODULE_WORKSPACE_ROOT"',
@@ -363,6 +433,7 @@ export class LmsWebBridge {
private port = 0
private sessions = new Map<string, BridgeSession>()
private terminals = new Map<string, TerminalSession>()
private sessionEventStreams = new Map<string, Set<ServerResponse>>()
async createSession(origin: string) {
await this.ensureStarted()
@@ -373,9 +444,22 @@ export class LmsWebBridge {
async stop() {
if (!this.server) return
this.invalidateSessions()
await new Promise<void>((resolvePromise) => this.server?.close(() => resolvePromise()))
this.server = null
this.port = 0
}
invalidateSessions(): void {
for (const streams of this.sessionEventStreams.values()) {
for (const response of streams) {
if (!response.writableEnded) {
response.write('event: session-expired\ndata: {}\n\n')
response.end()
}
}
}
this.sessionEventStreams.clear()
this.sessions.clear()
for (const terminal of this.terminals.values()) terminal.process.kill()
this.terminals.clear()
@@ -437,6 +521,32 @@ export class LmsWebBridge {
return
}
if (request.method === 'POST' && path === '/api/session/events') {
const token = request.headers['x-autonomy-lms-bridge-token']
const sessionToken = Array.isArray(token) ? token[0] : token
if (!sessionToken) throw new Error('LMS 웹 브리지 인증이 필요합니다.')
response.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'access-control-allow-origin': session.origin,
'access-control-allow-headers': 'content-type, x-autonomy-lms-bridge-token',
'access-control-allow-methods': 'GET, POST, OPTIONS',
vary: 'Origin',
})
response.write('event: ready\ndata: {}\n\n')
const streams = this.sessionEventStreams.get(sessionToken) ?? new Set<ServerResponse>()
streams.add(response)
this.sessionEventStreams.set(sessionToken, streams)
request.on('close', () => {
streams.delete(response)
if (!streams.size) this.sessionEventStreams.delete(sessionToken)
})
return
}
if (request.method !== 'POST') throw new Error('지원하지 않는 요청입니다.')
const body = await readBody(request)
if (path === '/api/modules/list') {
@@ -479,6 +589,10 @@ export class LmsWebBridge {
respond(response, 200, await this.readModuleFiles(body), session.origin)
return
}
if (path === '/api/files/image') {
respond(response, 200, await this.readModuleImage(body), session.origin)
return
}
if (path === '/api/files/create') {
respond(response, 200, await this.createModuleFile(body), session.origin)
return
@@ -499,6 +613,14 @@ export class LmsWebBridge {
respond(response, 200, await this.deleteModuleFile(body), session.origin)
return
}
if (path === '/api/world-layout/read') {
respond(response, 200, await this.readWorldLayout(body), session.origin)
return
}
if (path === '/api/world-layout/write') {
respond(response, 200, await this.writeWorldLayout(body), session.origin)
return
}
if (path === '/api/git/status') {
respond(response, 200, await this.gitStatus(body), session.origin)
return
@@ -547,6 +669,10 @@ export class LmsWebBridge {
respond(response, 200, await this.readRemoteFiles(body), session.origin)
return
}
if (path === '/api/remote/files/image') {
respond(response, 200, await this.readRemoteImage(body), session.origin)
return
}
if (path === '/api/remote/files/create') {
respond(response, 200, await this.remoteFileMutation(body, 'create'), session.origin)
return
@@ -603,6 +729,14 @@ export class LmsWebBridge {
respond(response, 200, await this.stopRemoteModule(body), session.origin)
return
}
if (path === '/api/remote/modules/stop-many') {
respond(response, 200, await this.stopRemoteModules(body), session.origin)
return
}
if (path === '/api/remote/modules/reset-session') {
respond(response, 200, await this.resetRemoteModules(body), session.origin)
return
}
if (path === '/api/remote/modules/logs') {
respond(response, 200, await this.remoteModuleLogs(body), session.origin)
return
@@ -645,6 +779,24 @@ export class LmsWebBridge {
return value
}
private async readWorldLayout(body: Record<string, unknown>): Promise<WorldLayout> {
const targetPath = worldLayoutPath(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
try {
return normalizeWorldLayout(JSON.parse(await readFile(targetPath, 'utf8')))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultWorldLayout()
throw error
}
}
private async writeWorldLayout(body: Record<string, unknown>): Promise<WorldLayout> {
const layout = normalizeWorldLayout(body.layout)
const targetPath = worldLayoutPath(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
await mkdir(dirname(targetPath), { recursive: true })
await writeFile(targetPath, `${JSON.stringify(layout, null, 2)}\n`, 'utf8')
return layout
}
private moduleRoot(body: Record<string, unknown>) {
const module = this.moduleFromBody(body)
const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
@@ -677,7 +829,16 @@ export class LmsWebBridge {
}
private ssh(target: RemoteWorkspaceTarget, script: string, timeoutMs = 30_000) {
return command('ssh', ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=8', '-p', String(target.port ?? 22), `${target.user}@${target.host}`, 'bash', '-s'], undefined, timeoutMs, script)
return command('ssh', [
'-o', 'BatchMode=yes',
'-o', 'IdentitiesOnly=yes',
'-o', 'StrictHostKeyChecking=accept-new',
'-o', 'ConnectTimeout=8',
'-i', LAUNCHER_SSH_IDENTITY_FILE,
'-p', String(target.port ?? 22),
`${target.user}@${target.host}`,
'bash', '-s',
], undefined, timeoutMs, script)
}
private async dockerStatuses() {
@@ -887,6 +1048,25 @@ export class LmsWebBridge {
return { root: basename(target), absoluteRoot: target, files, folders }
}
private imageMimeType(path: string): string {
const extension = path.split('.').pop()?.toLowerCase()
if (extension === 'png') return 'image/png'
if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg'
if (extension === 'webp') return 'image/webp'
if (extension === 'gif') return 'image/gif'
if (extension === 'svg') return 'image/svg+xml'
throw new Error('지원하지 않는 이미지 형식입니다.')
}
private async readModuleImage(body: Record<string, unknown>) {
const { absolutePath, relativePath } = this.moduleFilePath(body, 'path')
if (!imageFilePattern.test(relativePath)) throw new Error('지원하지 않는 이미지 형식입니다.')
const details = await stat(absolutePath)
if (details.size > maxImagePreviewBytes) throw new Error('이미지는 10MB 이하만 미리볼 수 있습니다.')
const content = await readFile(absolutePath)
return { dataUrl: `data:${this.imageMimeType(relativePath)};base64,${content.toString('base64')}` }
}
private async createModuleFile(body: Record<string, unknown>) {
const { absolutePath } = this.moduleFilePath(body, 'path')
const kind = body.kind
@@ -1133,7 +1313,11 @@ export class LmsWebBridge {
private remoteModule(body: Record<string, unknown>) {
const target = this.remoteTarget(body)
const module = this.moduleFromBody(body)
const rootScript = `ROOT=${quoteShell(target.workspaceRoot ?? '')}\ncase "$ROOT" in ~/*) ROOT="$HOME/${'$'}{ROOT#~/}";; esac\nMODULE="$ROOT/${module.folderName}"\n`
const workspaceRoot = target.workspaceRoot ?? ''
const rootAssignment = workspaceRoot.startsWith('~/')
? `ROOT="$HOME/${workspaceRoot.slice(2).replace(/"/g, '\\"')}"`
: `ROOT=${quoteShell(workspaceRoot)}`
const rootScript = `${rootAssignment}\nMODULE="$ROOT/${module.folderName}"\n`
return { target, module, rootScript }
}
@@ -1168,6 +1352,22 @@ done`
return { root: module.folderName, absoluteRoot: null, files, folders }
}
private async readRemoteImage(body: Record<string, unknown>) {
const { target, relativePath, rootScript, pathExpression } = this.remoteFilePath(body, 'path')
if (!imageFilePattern.test(relativePath)) throw new Error('지원하지 않는 이미지 형식입니다.')
const script = [
rootScript,
`FILE=${pathExpression}`,
'test -f "$FILE"',
'SIZE=$(wc -c < "$FILE")',
`test "$SIZE" -le ${maxImagePreviewBytes}`,
'base64 -w0 "$FILE"',
].join('\n')
const encoded = (await this.ssh(target, script, 30_000)).trim()
if (!encoded) throw new Error('이미지 내용을 읽을 수 없습니다.')
return { dataUrl: `data:${this.imageMimeType(relativePath)};base64,${encoded}` }
}
private async remoteFileMutation(body: Record<string, unknown>, action: 'create' | 'write' | 'copy' | 'rename' | 'delete') {
const from = action === 'copy' || action === 'rename' ? this.remoteFilePath(body, 'fromPath') : null
const target = from ?? this.remoteFilePath(body, 'path')
@@ -1216,7 +1416,7 @@ done`
const authorName = typeof body.authorName === 'string' && body.authorName.trim() ? body.authorName.trim() : 'Autonomy Studio LMS Bot'
const authorEmail = typeof body.authorEmail === 'string' && body.authorEmail.trim() ? body.authorEmail.trim() : 'lms-bot@futuredrive.net'
const paths = files.map((file) => quoteShell(file.path)).join(' ')
const script = [rootScript, 'set -e', `git -C "$MODULE" add -A -- ${paths}`, `git -C "$MODULE" -c user.name=${quoteShell(authorName)} -c user.email=${quoteShell(authorEmail)} commit -m ${quoteShell(message)}`, 'git -C "$MODULE" push -u origin HEAD', 'git -C "$MODULE" log -1 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict'].join('\n')
const script = [rootScript, 'set -e', `git -C "$MODULE" add -A -- ${paths}`, `git -C "$MODULE" -c user.name=${quoteShell(authorName)} -c user.email=${quoteShell(authorEmail)} commit -m ${quoteShell(message)}`, 'PUSH_REMOTE="$(git -C "$MODULE" remote get-url student >/dev/null 2>&1 && printf student || printf origin)"', 'git -C "$MODULE" push -u "$PUSH_REMOTE" HEAD:main', 'git -C "$MODULE" log -1 --pretty=format:%H%x1f%h%x1f%an%x1f%ad%x1f%s --date=iso-strict'].join('\n')
return { ok: true, message: '제출되었습니다.', commit: parseGitCommits(await this.ssh(target, script, 150_000))[0] }
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) }
@@ -1268,7 +1468,17 @@ done`
const cols = terminalSize(body.cols, 80, 20, 300)
const rows = terminalSize(body.rows, 24, 5, 120)
const shell = buildRemoteWorkspaceShellCommand(moduleName, remotePath, cols, rows)
const shellProcess = pty.spawn('ssh', ['-tt', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=8', '-p', String(target.port ?? 22), `${target.user}@${target.host}`, shell], {
const shellProcess = pty.spawn('ssh', [
'-tt',
'-o', 'BatchMode=yes',
'-o', 'IdentitiesOnly=yes',
'-o', 'StrictHostKeyChecking=accept-new',
'-o', 'ConnectTimeout=8',
'-i', LAUNCHER_SSH_IDENTITY_FILE,
'-p', String(target.port ?? 22),
`${target.user}@${target.host}`,
shell,
], {
name: 'xterm-256color',
cols,
rows,
@@ -1290,8 +1500,11 @@ done`
if (!terminal || terminal.exited) return { ok: false }
const target = this.remoteTarget(body)
const module = this.moduleFromBody({ module: { name: body.moduleName, folderName: body.folderName } })
const path = `${(target.workspaceRoot ?? '').replace(/\/+$/, '')}/${module.folderName}`
terminal.process.write(`cd ${quoteShell(path)}\n`)
const root = (target.workspaceRoot ?? '').replace(/\/+$/, '')
const path = root.startsWith('~/')
? `"$HOME/${root.slice(2)}/${module.folderName}"`
: quoteShell(`${root}/${module.folderName}`)
terminal.process.write(`cd ${path}\n`)
return { ok: true }
}
@@ -1337,24 +1550,66 @@ done`
return [name, { running: /^Up\b/i.test(status), status }]
}))
const root = target.workspaceRoot ?? ''
const modules = await Promise.all(requested.map(async (raw, index) => {
const modulesToCheck = requested.flatMap((raw, index) => {
const module = raw as CourseModule
if (!module?.name || !validFolderName(module.folderName)) return null
const installed = (await this.ssh(target, `[ -d ${quoteShell(`${root.replace(/\/+$/, '')}/${module.folderName}`)} ] && printf yes || printf no`, 10_000)).trim() === 'yes'
return module?.name && validFolderName(module.folderName) ? [{ module, index }] : []
})
const rootScript = root.startsWith('~/')
? `ROOT="$HOME/${root.slice(2).replace(/"/g, '\\"')}"`
: `ROOT=${quoteShell(root)}`
const installedOutput = modulesToCheck.length
? await this.ssh(target, [
rootScript,
...modulesToCheck.map(({ module }) => `[ -d "$ROOT/${module.folderName}" ] && printf '%s\\tyes\\n' ${quoteShell(module.folderName)} || printf '%s\\tno\\n' ${quoteShell(module.folderName)}`),
].join('\n'), 15_000)
: ''
const installedByFolder = new Map(installedOutput.split('\n').filter(Boolean).map((line) => {
const [folderName, installed] = line.split('\t')
return [folderName, installed === 'yes']
}))
const modules = modulesToCheck.map(({ module, index }) => {
const installed = installedByFolder.get(module.folderName) === true
const container = containerName(module.folderName)
const status = container ? docker.get(container) : undefined
return { ...module, sortOrder: Number(module.sortOrder) || index + 1, workspacePath: installed ? `${root}/${module.folderName}` : null, state: status?.running ? 'running' : installed ? 'stopped' : 'uninstalled', cpu: null, memory: null, runtime: status?.running ? this.formatRuntime(status.status) : '-' }
}))
return { workspaceRoot: root, modules: modules.filter(Boolean) }
return {
...module,
sortOrder: Number(module.sortOrder) || index + 1,
parameters: Array.isArray(module.defaultParameters) ? module.defaultParameters : [],
workspacePath: installed ? `${root}/${module.folderName}` : null,
state: status?.running ? 'running' : installed ? 'stopped' : 'uninstalled',
cpu: null,
memory: null,
runtime: status?.running ? this.formatRuntime(status.status) : '-',
}
})
return { workspaceRoot: root, modules }
}
private async installRemoteModule(body: Record<string, unknown>) {
const { target, module, rootScript } = this.remoteModule(body)
if (!validRepositoryUrl(module.templateRepositoryUrl)) throw new Error('템플릿 저장소 주소가 필요합니다.')
const studentUrl = validRepositoryUrl(module.studentRepositoryUrl) ? module.studentRepositoryUrl.trim() : ''
const script = [rootScript, 'mkdir -p "$ROOT"', '[ -d "$MODULE/.git" ] && exit 0', `git clone ${quoteShell(studentUrl || module.templateRepositoryUrl.trim())} "$MODULE"`].join('\n')
if (!studentUrl) throw new Error('학생 저장소 주소가 필요합니다.')
const templateUrl = module.templateRepositoryUrl.trim()
const script = [
rootScript,
'set -e',
'mkdir -p "$ROOT"',
`if [ ! -d "$MODULE/.git" ]; then git clone ${quoteShell(templateUrl)} "$MODULE"; fi`,
`git -C "$MODULE" remote set-url origin ${quoteShell(templateUrl)}`,
'git -C "$MODULE" fetch origin --prune',
`CURRENT_STUDENT="$(git -C "$MODULE" remote get-url student 2>/dev/null || true)"`,
`if [ "$CURRENT_STUDENT" = ${quoteShell(studentUrl)} ]; then exit 0; fi`,
'git -C "$MODULE" checkout -B main origin/main',
'git -C "$MODULE" reset --hard origin/main',
'git -C "$MODULE" clean -fdx',
'git -C "$MODULE" remote remove student >/dev/null 2>&1 || true',
`git -C "$MODULE" remote add student ${quoteShell(studentUrl)}`,
'git -C "$MODULE" fetch student --prune || true',
'if git -C "$MODULE" show-ref --verify --quiet refs/remotes/student/main; then git -C "$MODULE" checkout -B student-work student/main; else git -C "$MODULE" checkout -B student-work origin/main && git -C "$MODULE" push student student-work:main; fi',
].join('\n')
await this.ssh(target, script, 120_000)
return { ok: true, message: '모듈을 설치했습니다.', workspacePath: `${target.workspaceRoot}/${module.folderName}` }
return { ok: true, message: '학생 작업공간을 준비했습니다.', workspacePath: `${target.workspaceRoot}/${module.folderName}` }
}
private async stopRemoteModule(body: Record<string, unknown>) {
@@ -1369,6 +1624,61 @@ done`
return { ok: true, message: buildStatus.trim() && !status.trim() ? '빌드를 중지했습니다.' : '중지했습니다.' }
}
private async stopRemoteModules(body: Record<string, unknown>) {
const target = this.remoteTarget(body)
const requested = Array.isArray(body.modules) ? body.modules.slice(0, 100) : []
const containers = Array.from(new Set(requested.flatMap((raw) => {
const module = raw as CourseModule
return validFolderName(module?.folderName) ? [containerName(module.folderName)] : []
}).filter((value): value is string => Boolean(value))))
if (!containers.length) return { ok: true, message: '중지할 컨테이너가 없습니다.' }
const buildContainers = containers.map((container) => `${container}-build`)
const script = [
'set -e',
`docker rm -f ${buildContainers.map(quoteShell).join(' ')} >/dev/null 2>&1 || true`,
`docker stop ${containers.map(quoteShell).join(' ')} >/dev/null 2>&1 || true`,
].join('\n')
await this.ssh(target, script, 30_000)
return { ok: true, message: '중지했습니다.' }
}
private async resetRemoteModules(body: Record<string, unknown>) {
const target = this.remoteTarget(body)
const requested = Array.isArray(body.modules) ? body.modules.slice(0, 100) : []
const modules = requested.flatMap((raw) => {
const module = raw as CourseModule
return module?.name && validFolderName(module.folderName) && validRepositoryUrl(module.templateRepositoryUrl)
? [module]
: []
})
const workspaceRoot = target.workspaceRoot ?? ''
const rootAssignment = workspaceRoot.startsWith('~/')
? `ROOT="$HOME/${workspaceRoot.slice(2).replace(/"/g, '\\"')}"`
: `ROOT=${quoteShell(workspaceRoot)}`
const commands = [rootAssignment, 'set -e']
for (const module of modules) {
const container = containerName(module.folderName)
if (container) commands.push(`docker rm -f ${quoteShell(container)} ${quoteShell(`${container}-build`)} >/dev/null 2>&1 || true`)
commands.push(
`MODULE="$ROOT/${module.folderName}"`,
'if [ -d "$MODULE/.git" ]; then',
' git -C "$MODULE" remote remove student >/dev/null 2>&1 || true',
` git -C "$MODULE" remote set-url origin ${quoteShell(module.templateRepositoryUrl!.trim())}`,
' git -C "$MODULE" fetch origin --prune',
' git -C "$MODULE" checkout -B main origin/main',
' git -C "$MODULE" reset --hard origin/main',
' git -C "$MODULE" clean -fdx',
'fi',
)
}
if (modules.length) await this.ssh(target, commands.join('\n'), 180_000)
return { ok: true, message: '플랫폼 모듈 작업공간을 템플릿 기준으로 초기화했습니다.' }
}
private async startRemoteModule(body: Record<string, unknown>) {
const { target, module, rootScript } = this.remoteModule(body)
const container = containerName(module.folderName)
@@ -1377,6 +1687,13 @@ done`
const requestedParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : null
const gpuEnabled = body.gpuEnabled === true
const buildOnly = body.buildOnly === true
const configuredImage = typeof body.dockerImage === 'string' ? body.dockerImage.trim() : module.dockerImage?.trim()
const nav2Launch = nav2LaunchSpec(module.folderName)
const runtimeImage = configuredImage || (nav2Launch ? 'nav2-dev:humble' : AUTOWARE_IMAGE)
if (!/^[A-Za-z0-9._/:@-]+$/.test(runtimeImage)) throw new Error('모듈 런타임 이미지가 올바르지 않습니다.')
const nav2RunCommand = nav2Launch
? `set -e; source /opt/ros/humble/setup.bash; for setup in /modules/*/install/setup.bash; do [ -f "${'$'}setup" ] && source "${'$'}setup"; done; exec ros2 launch ${nav2Launch.packageName} ${nav2Launch.launchFile} ${'$'}1`
: ''
const runtimeImages = (Array.isArray(body.runtimeImages) ? body.runtimeImages : [])
.filter((value): value is RuntimeImageArtifact => Boolean(value && typeof value === 'object'))
.map((value) => ({
@@ -1429,11 +1746,19 @@ done`
? [
`BUILD_CONTAINER=${quoteShell(`${container}-build`)}`,
'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true',
`docker run --rm --name "$BUILD_CONTAINER" --volume="$MODULE:/autoware:rw" -u root --net=host -e ROS_DOMAIN_ID=${quoteShell(rosDomainId)} -v /etc/localtime:/etc/localtime:ro --entrypoint /bin/bash ${quoteShell(AUTOWARE_IMAGE)} -lc 'cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install'`,
nav2Launch
? `docker run --rm --name "$BUILD_CONTAINER" --volume="$ROOT:/modules:rw" -w "/modules/${module.folderName}" -u root --net=host -e ROS_DOMAIN_ID=${quoteShell(rosDomainId)} -v /etc/localtime:/etc/localtime:ro --entrypoint /bin/bash ${quoteShell(runtimeImage)} -lc 'source /opt/ros/humble/setup.bash; for setup in /modules/*/install/setup.bash; do [ -f "$setup" ] && source "$setup"; done; colcon build --symlink-install'`
: `docker run --rm --name "$BUILD_CONTAINER" --volume="$MODULE:/autoware:rw" -u root --net=host -e ROS_DOMAIN_ID=${quoteShell(rosDomainId)} -v /etc/localtime:/etc/localtime:ro --entrypoint /bin/bash ${quoteShell(runtimeImage)} -lc 'cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install'`,
]
: [
...(nav2Launch
? [
`docker run -d --name ${quoteShell(container)} --network host --ipc host --volume="$ROOT:/modules:rw" -w "/modules/${module.folderName}" -e ROS_DOMAIN_ID=${quoteShell(rosDomainId)} -e TURTLEBOT3_MODEL=burger -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" -v /etc/localtime:/etc/localtime:ro --entrypoint /bin/bash ${quoteShell(runtimeImage)} -lc ${quoteShell(nav2RunCommand)} _ "$PARAMS"`,
]
: [
'cd "$RUNTIME_ROOT"',
`SHARD_DIR="$ROOT" bash "$DOCKER_ROOT/docker_run_autoware-universe:humble-20240301-cuda.sh" ${quoteShell(container)} ${quoteShell(module.folderName)} localhost localhost ${quoteShell(AUTOWARE_IMAGE)} "$PARAMS" ${quoteShell(rosDomainId)} ${gpuEnabled ? 'true' : 'false'} false`,
`SHARD_DIR="$ROOT" bash "$DOCKER_ROOT/docker_run_autoware-universe:humble-20240301-cuda.sh" ${quoteShell(container)} ${quoteShell(module.folderName)} localhost localhost ${quoteShell(runtimeImage)} "$PARAMS" ${quoteShell(rosDomainId)} ${gpuEnabled ? 'true' : 'false'} false`,
]),
]),
].filter(Boolean).join('\n'), 900_000)
return { ok: true, message: buildOnly ? '빌드가 완료되었습니다.' : '실행 요청을 보냈습니다.' }
+7
View File
@@ -17,6 +17,7 @@ const launcherTailscale = {
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
restartService: () => ipcRenderer.invoke('launcher-tailscale-restart-service'),
}
const launcherSshKey = {
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
@@ -29,6 +30,9 @@ const launcherAuth = {
return () => ipcRenderer.removeListener('google-oauth-callback', listener)
},
}
const launcherSession = {
endUserSession: () => ipcRenderer.invoke('launcher-end-user-session'),
}
if (process.contextIsolated) {
try {
@@ -37,6 +41,7 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('launcherSession', launcherSession)
} catch (error) {
console.error(error)
}
@@ -51,4 +56,6 @@ if (process.contextIsolated) {
window.launcherSshKey = launcherSshKey
// @ts-ignore
window.launcherAuth = launcherAuth
// @ts-ignore
window.launcherSession = launcherSession
}
+34 -24
View File
@@ -1,10 +1,14 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import AppWindowShell from './pages/AppWindowShell'
import type { AuthSession } from './pages/LoginForm'
import { refreshLauncherSession } from './auth/sessionRefresh'
import {
blockLauncherSessionRefresh,
refreshLauncherSession,
resetLauncherSessionRefresh,
} from './auth/sessionRefresh'
const AUTH_SESSION_KEY = 'atnmy.auth.session'
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
@@ -19,6 +23,7 @@ function App(): React.JSX.Element {
}
})
const [sessionExpiredOpen, setSessionExpiredOpen] = useState(false)
const sessionExpiredRef = useRef(false)
const meta = window.appWindowMeta
if (meta?.isAppWindow) {
@@ -26,6 +31,8 @@ function App(): React.JSX.Element {
}
const handleSessionUpdate = useCallback((session: AuthSession): void => {
if (sessionExpiredRef.current) return
const nextSession: AuthSession = {
...session,
user: {
@@ -51,6 +58,8 @@ function App(): React.JSX.Element {
}, [])
const handleLogout = useCallback((): void => {
blockLauncherSessionRefresh()
window.launcherSession?.endUserSession().catch(() => undefined)
requestLogout(authSession?.accessToken)
window.launcherTailscale?.logout().catch(() => undefined)
window.localStorage.removeItem(AUTH_SESSION_KEY)
@@ -59,6 +68,10 @@ function App(): React.JSX.Element {
}, [authSession?.accessToken, requestLogout])
const handleSessionExpired = useCallback((): void => {
if (sessionExpiredRef.current) return
sessionExpiredRef.current = true
blockLauncherSessionRefresh()
window.launcherSession?.endUserSession().catch(() => undefined)
setSessionExpiredOpen(true)
}, [])
@@ -69,28 +82,19 @@ function App(): React.JSX.Element {
setAuthSession(null)
}, [])
useEffect(() => {
if (!authSession?.accessToken) return
const accessToken = authSession.accessToken
const handleSessionClose = (): void => {
requestLogout(accessToken)
}
window.addEventListener('pagehide', handleSessionClose)
window.addEventListener('beforeunload', handleSessionClose)
return () => {
window.removeEventListener('pagehide', handleSessionClose)
window.removeEventListener('beforeunload', handleSessionClose)
}
}, [authSession?.accessToken, requestLogout])
const handleLogin = useCallback((session: AuthSession): void => {
sessionExpiredRef.current = false
resetLauncherSessionRefresh()
setSessionExpiredOpen(false)
setAuthSession(session)
}, [])
useEffect(() => {
if (!authSession?.accessToken) return
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
const timer = window.setTimeout(() => {
let timer: number | null = null
const refresh = (): void => {
refreshLauncherSession()
.then((session) => {
if (session?.accessToken) {
@@ -99,19 +103,25 @@ function App(): React.JSX.Element {
handleSessionExpired()
}
})
.catch(() => {
handleSessionExpired()
.catch((error: unknown) => {
console.warn('[launcher] session refresh failed; retrying in 30 seconds', error)
timer = window.setTimeout(refresh, 30_000)
})
}, refreshDelayMs)
}
return () => window.clearTimeout(timer)
timer = window.setTimeout(refresh, refreshDelayMs)
return () => {
if (timer !== null) window.clearTimeout(timer)
}
}, [authSession, handleSessionExpired, handleSessionUpdate])
return (
<>
{authSession
&& !sessionExpiredOpen
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} onSessionExpired={handleSessionExpired} />
: <Login onLogin={setAuthSession} />}
: <Login onLogin={handleLogin} />}
{sessionExpiredOpen && createPortal(
<div className="fixed inset-0 z-[20000] flex items-center justify-center bg-[rgba(2,6,18,0.5)] px-4 backdrop-blur-[6px]">
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-[rgba(255,255,255,0.18)] bg-[rgba(22,27,39,0.86)] shadow-[0_28px_90px_rgba(0,0,0,0.58),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-2xl">
+18 -2
View File
@@ -3,16 +3,32 @@ import type { AuthSession } from '../pages/LoginForm'
const launcherApiBaseUrl = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
let pendingRefresh: Promise<AuthSession | null> | null = null
let refreshBlocked = false
export function blockLauncherSessionRefresh(): void {
refreshBlocked = true
}
export function resetLauncherSessionRefresh(): void {
refreshBlocked = false
}
export function refreshLauncherSession(): Promise<AuthSession | null> {
if (refreshBlocked) return Promise.resolve(null)
if (pendingRefresh) return pendingRefresh
pendingRefresh = fetch(`${launcherApiBaseUrl}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
.then((response) => response.ok ? response.json() as Promise<AuthSession> : null)
.catch(() => null)
.then((response) => {
if (response.ok) return response.json() as Promise<AuthSession>
if (response.status === 401 || response.status === 403) {
refreshBlocked = true
return null
}
throw new Error(`세션 갱신 요청에 실패했습니다. (${response.status})`)
})
.finally(() => {
pendingRefresh = null
})
+145 -8
View File
@@ -21,12 +21,17 @@ import {
MonitorCog,
Palette,
Info,
Network
Network,
Copy,
Check
} from 'lucide-react'
import type { LauncherApp } from '../config/apps'
const ICON_SIZE = 14
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const PLATFORM_AGENT_INSTALL_URL = import.meta.env.VITE_PLATFORM_AGENT_INSTALL_URL
|| 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent/install.sh'
const PLATFORM_AGENT_INSTALL_COMMAND = `curl -fsSL ${PLATFORM_AGENT_INSTALL_URL} | bash`
const APP_ICONS: Record<string, React.ReactNode> = {
code: <Code2 size={ICON_SIZE} />,
@@ -82,7 +87,7 @@ interface SidebarProps {
}
type UserModal = 'profile' | 'settings' | 'platforms' | null
type SettingsTabKey = 'general' | 'theme' | 'info'
type SettingsTabKey = 'general' | 'remote' | 'theme' | 'info'
type VpnPlatformPeer = {
id: string
hostName: string
@@ -419,6 +424,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
launcherUpdating={launcherUpdating}
launcherUpdateMessage={launcherUpdateMessage}
onLauncherUpdate={onLauncherUpdate}
onTailscaleRetry={onTailscaleRetry}
onClose={() => setModal(null)}
/>
)}
@@ -438,6 +444,8 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
const [registrationNow, setRegistrationNow] = useState(() => Date.now())
const [registrationLoading, setRegistrationLoading] = useState(false)
const [authorizationError, setAuthorizationError] = useState('')
const [registrationGuideOpen, setRegistrationGuideOpen] = useState(false)
const [registrationGuideCopied, setRegistrationGuideCopied] = useState(false)
const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
setLoading(true)
@@ -590,6 +598,16 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
}
}
const copyRegistrationGuide = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(PLATFORM_AGENT_INSTALL_COMMAND)
setRegistrationGuideCopied(true)
window.setTimeout(() => setRegistrationGuideCopied(false), 1600)
} catch {
setAuthorizationError('설치 명령을 복사하지 못했습니다.')
}
}
return (
<ModalShell
title="플랫폼 관리"
@@ -638,16 +656,16 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
key={platform.id}
type="button"
onClick={() => setSelectedId(platform.id)}
className={`flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors cursor-pointer ${
className={`flex w-full min-w-0 overflow-hidden items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors cursor-pointer ${
active
? 'border-[rgba(103,232,249,0.42)] bg-[rgba(103,232,249,0.12)]'
: 'border-transparent hover:border-white/10 hover:bg-white/[0.05]'
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-[#35d27f]' : 'bg-[#ef4444]'}`} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] font-bold text-white/86">{platform.hostName}</span>
<span className="mt-0.5 block truncate text-[11px] font-medium text-white/42">{platform.dnsName || platform.loginName || '플랫폼 장비'}</span>
<span className="min-w-0 flex-1 overflow-hidden">
<span title={platform.hostName} className="block truncate text-[12px] font-bold text-white/86">{platform.hostName}</span>
<span title={platform.dnsName || platform.loginName || '플랫폼 장비'} className="mt-0.5 block truncate text-[11px] font-medium text-white/42">{platform.dnsName || platform.loginName || '플랫폼 장비'}</span>
</span>
</button>
)
@@ -664,15 +682,42 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
<p className="text-[13px] font-bold text-white/86">플랫폼 등록</p>
<p className="mt-1 text-[11px] font-medium leading-5 text-white/48">에이전트 설치 화면에서 현재 계정 이메일과 등록 키를 입력하세요.</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<button
type="button"
onClick={() => void createAgentRegistrationCode()}
disabled={registrationLoading}
className="h-8 shrink-0 cursor-pointer rounded-md bg-[#5588ff] px-3.5 text-[12px] font-bold text-white transition-colors hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-50"
className="h-7 w-[72px] cursor-pointer rounded-md bg-[#5588ff] text-[11px] font-bold text-white transition-colors hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-50"
>
키 발급
</button>
<button
type="button"
aria-expanded={registrationGuideOpen}
onClick={() => setRegistrationGuideOpen((open) => !open)}
className="h-7 w-[72px] cursor-pointer rounded-md border border-white/14 text-[11px] font-bold text-white/65 transition-colors hover:border-white/24 hover:bg-white/[0.06] hover:text-white"
>
등록 방법
</button>
</div>
</div>
{registrationGuideOpen && (
<div className="mt-3 rounded-md border border-white/10 bg-black/20 px-3 py-2.5">
<p className="text-[11px] font-medium leading-5 text-white/48">플랫폼 장비의 터미널에서 아래 명령을 실행하세요.</p>
<div className="mt-2 flex items-start gap-2">
<code className="min-w-0 flex-1 select-all break-all font-mono text-[10px] font-medium leading-5 text-white/72">{PLATFORM_AGENT_INSTALL_COMMAND}</code>
<button
type="button"
title="설치 명령 복사"
aria-label="설치 명령 복사"
onClick={() => void copyRegistrationGuide()}
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md border border-white/14 text-white/65 transition-colors hover:border-white/24 hover:bg-white/[0.06] hover:text-white"
>
{registrationGuideCopied ? <Check size={13} className="text-[#85efac]" /> : <Copy size={13} />}
</button>
</div>
</div>
)}
{registrationCode && (
<div className="mt-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
@@ -1100,19 +1145,61 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
)
}
function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, onClose }: {
function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, onTailscaleRetry, onClose }: {
launcherUpdateAvailable: boolean
launcherUpdating: boolean
launcherUpdateMessage: string
onLauncherUpdate: () => void
onTailscaleRetry?: () => void
onClose: () => void
}): React.JSX.Element {
const [activeTab, setActiveTab] = useState<SettingsTabKey>('general')
const [clearingCache, setClearingCache] = useState(false)
const [cacheAvailable, setCacheAvailable] = useState(true)
const [cacheMessage, setCacheMessage] = useState('')
const [tailscaleStatus, setTailscaleStatus] = useState<Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null>(null)
const [tailscaleLoading, setTailscaleLoading] = useState(false)
const [tailscaleRestarting, setTailscaleRestarting] = useState(false)
const [tailscaleMessage, setTailscaleMessage] = useState('')
const version = import.meta.env.VITE_APP_VERSION
const loadTailscaleStatus = useCallback(async (): Promise<void> => {
if (!window.launcherTailscale) {
setTailscaleMessage('데스크톱 런처에서만 원격 연결 상태를 확인할 수 있습니다.')
return
}
setTailscaleLoading(true)
try {
const status = await window.launcherTailscale.status()
setTailscaleStatus(status)
setTailscaleMessage(status.error ?? '')
} catch {
setTailscaleMessage('원격 연결 상태를 확인하지 못했습니다.')
} finally {
setTailscaleLoading(false)
}
}, [])
useEffect(() => {
if (activeTab === 'remote') void loadTailscaleStatus()
}, [activeTab, loadTailscaleStatus])
const restartTailscaleService = async (): Promise<void> => {
if (!window.launcherTailscale?.restartService || tailscaleRestarting) return
setTailscaleRestarting(true)
setTailscaleMessage('')
try {
const result = await window.launcherTailscale.restartService()
setTailscaleMessage(result.message)
if (result.ok) await loadTailscaleStatus()
} catch {
setTailscaleMessage('원격 연결 서비스 재시작에 실패했습니다.')
} finally {
setTailscaleRestarting(false)
}
}
const handleClearCache = async (): Promise<void> => {
if (!cacheAvailable) return
@@ -1154,6 +1241,7 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda
<div className="grid h-full grid-cols-[138px_1fr]">
<aside className="border-r border-[rgba(255,255,255,0.08)] p-3">
<SettingsTab icon={<MonitorCog size={15} />} label="일반" active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<SettingsTab icon={<Network size={15} />} label="원격 연결" active={activeTab === 'remote'} onClick={() => setActiveTab('remote')} />
<SettingsTab icon={<Palette size={15} />} label="테마" active={activeTab === 'theme'} onClick={() => setActiveTab('theme')} />
<SettingsTab icon={<Info size={15} />} label="정보" active={activeTab === 'info'} onClick={() => setActiveTab('info')} />
</aside>
@@ -1219,6 +1307,55 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda
</>
)}
{activeTab === 'remote' && (
<>
<h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">원격 연결</h3>
<SettingBlock title="연결 상태" description="현재 연결 상태">
<div className="flex min-w-[150px] flex-col items-end gap-1.5">
<span className={`text-[11px] font-bold ${tailscaleStatus?.connected ? 'text-[#58d68d]' : 'text-[#f3b44f]'}`}>
{tailscaleLoading ? '확인 중' : tailscaleStatus?.connected ? '연결됨' : '연결 필요'}
</span>
{tailscaleStatus?.ip && <span className="text-[11px] text-[var(--text-muted)]">{tailscaleStatus.ip}</span>}
</div>
</SettingBlock>
<SettingBlock title="계정" description="로그인된 계정">
<span className="max-w-[220px] truncate text-right text-[12px] font-medium text-[var(--text-secondary)]">
{tailscaleStatus?.loginName || tailscaleStatus?.userName || '-'}
</span>
</SettingBlock>
<SettingBlock title="연결 다시 시도" description="연결 정보를 다시 확인">
<button
type="button"
onClick={() => {
onTailscaleRetry?.()
void loadTailscaleStatus()
}}
disabled={!onTailscaleRetry || tailscaleLoading}
className={`rounded-md border border-[rgba(255,255,255,0.14)] px-3 py-2 text-[12px] font-semibold transition-colors ${
onTailscaleRetry && !tailscaleLoading ? 'cursor-pointer text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white' : 'cursor-not-allowed text-white/35 opacity-60'
}`}
>
다시 연결
</button>
</SettingBlock>
<SettingBlock title="서비스 재시작" description="tailscaled 서비스 재시작" last>
<div className="flex flex-col items-end gap-1.5">
<button
type="button"
onClick={() => void restartTailscaleService()}
disabled={!window.launcherTailscale?.restartService || tailscaleRestarting}
className={`rounded-md border border-[rgba(255,255,255,0.14)] px-3 py-2 text-[12px] font-semibold transition-colors ${
window.launcherTailscale?.restartService && !tailscaleRestarting ? 'cursor-pointer text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white' : 'cursor-not-allowed text-white/35 opacity-60'
}`}
>
{tailscaleRestarting ? '재시작 중' : '서비스 재시작'}
</button>
{tailscaleMessage && <span className="max-w-[230px] text-right text-[11px] leading-4 text-[var(--text-muted)]">{tailscaleMessage}</span>}
</div>
</SettingBlock>
</>
)}
{activeTab === 'info' && (
<>
<h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">정보</h3>
+7
View File
@@ -48,6 +48,10 @@ declare global {
ok: boolean
message: string
}>
restartService: () => Promise<{
ok: boolean
message: string
}>
}
launcherSshKey?: {
ensure: () => Promise<{
@@ -62,6 +66,9 @@ declare global {
callback: (payload: { loginToken?: string; error?: string }) => void
) => () => void
}
launcherSession?: {
endUserSession: () => Promise<{ ok: boolean }>
}
}
}
+20 -7
View File
@@ -18,7 +18,10 @@ interface DashboardProps {
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const MANUAL_BASE_URL = (import.meta.env.VITE_MANUAL_BASE_URL || 'https://portal.autonomystudio.net/manual').replace(/\/+$/, '')
const LMS_WEB_BASE_URL = `${(import.meta.env.VITE_LMS_WEB_BASE_URL || 'https://workspace.autonomystudio.net/lms-web/').replace(/\/+$/, '')}/`
const LMS_WEB_BASE_URL = `${(
import.meta.env.VITE_LMS_WEB_BASE_URL
|| (import.meta.env.DEV ? 'http://localhost:5177' : 'https://workspace.autonomystudio.net/lms-web/')
).replace(/\/+$/, '')}/`
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
const IS_DEV = import.meta.env.DEV
@@ -159,7 +162,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
const noticeRef = useRef<HTMLDivElement>(null)
const appActionMenuRef = useRef<HTMLDivElement>(null)
const initialAppSelectionDoneRef = useRef(false)
const tailscaleProvisionUserRef = useRef('')
const authSessionRef = useRef(authSession)
useEffect(() => {
authSessionRef.current = authSession
}, [authSession])
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
const hasNotices = appNotices.length > 0
@@ -188,10 +195,15 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
: null
const activeAppDisplayName = appStatus?.displayName || appConfig?.displayName || activeApp
const refreshSession = useCallback(async (): Promise<AuthSession | null> => {
try {
const session = await refreshLauncherSession()
if (!session) return null
onSessionUpdate(session)
return session
} catch (error) {
console.warn('[launcher] session refresh temporarily failed', error)
return authSessionRef.current
}
}, [onSessionUpdate])
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
@@ -216,14 +228,14 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
response = await request(refreshed.accessToken)
return response
}, [authSession.accessToken, onSessionExpired, refreshSession])
const fetchWithAuthRefreshRef = useRef(fetchWithAuthRefresh)
fetchWithAuthRefreshRef.current = fetchWithAuthRefresh
useEffect(() => {
const launcherTailscale = window.launcherTailscale
if (!launcherTailscale) return
const userEmail = authSession.user.email.trim().toLowerCase()
const provisionKey = `${userEmail}:${tailscaleRetryNonce}`
if (!userEmail || tailscaleProvisionUserRef.current === provisionKey) return
tailscaleProvisionUserRef.current = provisionKey
if (!userEmail) return
let cancelled = false
const connectTailscaleInBackground = async (): Promise<void> => {
@@ -260,7 +272,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
if (cancelled) return
const response = await fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/provision/session`, {
const response = await fetchWithAuthRefreshRef.current(`${LAUNCHER_API_BASE_URL}/api/provision/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -321,6 +333,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
}
})(), TAILSCALE_BACKGROUND_TIMEOUT_MS, '원격 연결 확인 시간이 초과되었습니다.')
} catch (error) {
if (cancelled) return
setTailscaleIssue({
state: 'warning',
title: '원격 연결 확인 필요',
@@ -335,7 +348,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
return () => {
cancelled = true
}
}, [authSession.user.email, fetchWithAuthRefresh, tailscaleRetryNonce])
}, [authSession.user.email, tailscaleRetryNonce])
useEffect(() => {
let cancelled = false
+3
View File
@@ -182,6 +182,9 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
if (response.status === 429) {
throw new Error('로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해주세요.')
}
throw new Error('이메일 또는 비밀번호를 확인하세요.')
}
const session = await response.json() as AuthSession