diff --git a/package.json b/package.json index 18cd03c..79d3137 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/main/index.ts b/src/main/index.ts index 505e60c..15e98b6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, appWindows: Map, ): 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 { } } +async function restartTailscaleService(): Promise { + 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 diff --git a/src/main/lms-web-bridge.ts b/src/main/lms-web-bridge.ts index 6f6d40e..2e7c997 100644 --- a/src/main/lms-web-bridge.ts +++ b/src/main/lms-web-bridge.ts @@ -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 + 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 = { + 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() private terminals = new Map() + private sessionEventStreams = new Map>() 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((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() + 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): Promise { + 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): Promise { + 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) { 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) { + 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) { const { absolutePath } = this.moduleFilePath(body, 'path') const kind = body.kind @@ -1133,7 +1313,11 @@ export class LmsWebBridge { private remoteModule(body: Record) { 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) { + 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, 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) { 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) { @@ -1369,6 +1624,61 @@ done` return { ok: true, message: buildStatus.trim() && !status.trim() ? '빌드를 중지했습니다.' : '중지했습니다.' } } + private async stopRemoteModules(body: Record) { + 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) { + 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) { 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'`, ] : [ - '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`, + ...(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(runtimeImage)} "$PARAMS" ${quoteShell(rosDomainId)} ${gpuEnabled ? 'true' : 'false'} false`, + ]), ]), ].filter(Boolean).join('\n'), 900_000) return { ok: true, message: buildOnly ? '빌드가 완료되었습니다.' : '실행 요청을 보냈습니다.' } diff --git a/src/preload/index.ts b/src/preload/index.ts index f48f770..dfdf753 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6290485..11dbe5f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ? - : } + : } {sessionExpiredOpen && createPortal(
diff --git a/src/renderer/src/auth/sessionRefresh.ts b/src/renderer/src/auth/sessionRefresh.ts index b323fed..a2f18a9 100644 --- a/src/renderer/src/auth/sessionRefresh.ts +++ b/src/renderer/src/auth/sessionRefresh.ts @@ -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 | null = null +let refreshBlocked = false + +export function blockLauncherSessionRefresh(): void { + refreshBlocked = true +} + +export function resetLauncherSessionRefresh(): void { + refreshBlocked = false +} export function refreshLauncherSession(): Promise { + 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 : null) - .catch(() => null) + .then((response) => { + if (response.ok) return response.json() as Promise + if (response.status === 401 || response.status === 403) { + refreshBlocked = true + return null + } + throw new Error(`세션 갱신 요청에 실패했습니다. (${response.status})`) + }) .finally(() => { pendingRefresh = null }) diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 164ccf7..f0f6e7d 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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 = { code: , @@ -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 => { setLoading(true) @@ -590,6 +598,16 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string } } + const copyRegistrationGuide = async (): Promise => { + try { + await navigator.clipboard.writeText(PLATFORM_AGENT_INSTALL_COMMAND) + setRegistrationGuideCopied(true) + window.setTimeout(() => setRegistrationGuideCopied(false), 1600) + } catch { + setAuthorizationError('설치 명령을 복사하지 못했습니다.') + } + } + return ( 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]' }`} > - - {platform.hostName} - {platform.dnsName || platform.loginName || '플랫폼 장비'} + + {platform.hostName} + {platform.dnsName || platform.loginName || '플랫폼 장비'} ) @@ -664,15 +682,42 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string

플랫폼 등록

에이전트 설치 화면에서 현재 계정 이메일과 등록 키를 입력하세요.

- +
+ + +
+ {registrationGuideOpen && ( +
+

플랫폼 장비의 터미널에서 아래 명령을 실행하세요.

+
+ {PLATFORM_AGENT_INSTALL_COMMAND} + +
+
+ )} {registrationCode && (
@@ -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('general') const [clearingCache, setClearingCache] = useState(false) const [cacheAvailable, setCacheAvailable] = useState(true) const [cacheMessage, setCacheMessage] = useState('') + const [tailscaleStatus, setTailscaleStatus] = useState['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 => { + 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 => { + 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 => { if (!cacheAvailable) return @@ -1154,6 +1241,7 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda
@@ -1219,6 +1307,55 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda )} + {activeTab === 'remote' && ( + <> +

원격 연결

+ +
+ + {tailscaleLoading ? '확인 중' : tailscaleStatus?.connected ? '연결됨' : '연결 필요'} + + {tailscaleStatus?.ip && {tailscaleStatus.ip}} +
+
+ + + {tailscaleStatus?.loginName || tailscaleStatus?.userName || '-'} + + + + + + +
+ + {tailscaleMessage && {tailscaleMessage}} +
+
+ + )} + {activeTab === 'info' && ( <>

정보

diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index a2f3158..d0b1403 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -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 }> + } } } diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 01ceac4..e03890a 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -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(null) const appActionMenuRef = useRef(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 => { - const session = await refreshLauncherSession() - if (!session) return null - onSessionUpdate(session) - return session + 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 => { @@ -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 => { @@ -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 diff --git a/src/renderer/src/pages/LoginForm.tsx b/src/renderer/src/pages/LoginForm.tsx index 506112f..2c2705e 100644 --- a/src/renderer/src/pages/LoginForm.tsx +++ b/src/renderer/src/pages/LoginForm.tsx @@ -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