Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ce10cb97 | |||
| 98875dc638 | |||
| fee219ce78 | |||
| 587ef6846c | |||
| 8d6ce50e6d | |||
| a5ee4221c4 | |||
| d7316449c2 | |||
| de40ef4ebb | |||
| 6e67684579 | |||
| 1fda517a0b | |||
| 4c2b98f99f | |||
| 7dbd07db8c | |||
| 6b14c3928f | |||
| fb403a2b30 | |||
| 4af21c5af5 | |||
| 751d334743 | |||
| 785da57ab1 | |||
| feb3480598 | |||
| 4d95c94b4f | |||
| 3ea9842c03 | |||
| 4e2136f03f |
-19
@@ -5,22 +5,3 @@ out/
|
|||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Direct `tsc -p` output. Electron Vite owns application compilation.
|
|
||||||
/electron.vite.config.js
|
|
||||||
/electron.vite.config.d.ts
|
|
||||||
/src/main/*.js
|
|
||||||
/src/main/*.d.ts
|
|
||||||
/src/main/**/*.js
|
|
||||||
/src/main/**/*.d.ts
|
|
||||||
/src/preload/*.js
|
|
||||||
/src/preload/*.d.ts
|
|
||||||
/src/preload/**/*.js
|
|
||||||
/src/preload/**/*.d.ts
|
|
||||||
/src/renderer/src/*.js
|
|
||||||
/src/renderer/src/*.jsx
|
|
||||||
/src/renderer/src/*.d.ts
|
|
||||||
/src/renderer/src/**/*.js
|
|
||||||
/src/renderer/src/**/*.jsx
|
|
||||||
/src/renderer/src/**/*.d.ts
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|||||||
@@ -8,13 +8,9 @@ const { version } = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
|
|
||||||
build: { rollupOptions: { input: resolve('src/main/index.ts') } },
|
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
|
|
||||||
build: { rollupOptions: { input: resolve('src/preload/index.ts') } },
|
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
@@ -23,7 +19,6 @@ export default defineConfig({
|
|||||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(version)
|
'import.meta.env.VITE_APP_VERSION': JSON.stringify(version)
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'],
|
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src')
|
'@renderer': resolve('src/renderer/src')
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "autonomy-studio-launcher",
|
"name": "autonomy-studio-launcher",
|
||||||
"version": "1.1.5",
|
"version": "1.0.27",
|
||||||
"description": "Autonomy Studio v2 Frontend",
|
"description": "Autonomy Studio v2 Frontend",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -14,13 +14,13 @@
|
|||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"start": "electron-vite preview --config electron.vite.config.ts",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev --config electron.vite.config.ts",
|
"dev": "electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build --config electron.vite.config.ts",
|
"build": "npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
"build:win": "npm run build && electron-builder --win",
|
"build:win": "npm run build && electron-builder --win",
|
||||||
"build:mac": "electron-vite build --config electron.vite.config.ts && electron-builder --mac",
|
"build:mac": "electron-vite build && electron-builder --mac",
|
||||||
"build:linux": "npm run build && electron-builder --linux"
|
"build:linux": "npm run build && electron-builder --linux"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
const { test } = require('node:test')
|
|
||||||
const assert = require('node:assert/strict')
|
|
||||||
const { readFileSync } = require('node:fs')
|
|
||||||
const { join } = require('node:path')
|
|
||||||
const ts = require('typescript')
|
|
||||||
const source = readFileSync(join(__dirname, '../src/main/appImageFuse.ts'), 'utf8')
|
|
||||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
|
||||||
const api = {}
|
|
||||||
new Function('exports', compiled)(api)
|
|
||||||
const cache = 'libfuse.so.2 (libc6,x86-64) => /lib/x86_64-linux-gnu/libfuse.so.2'
|
|
||||||
|
|
||||||
function fixture(options = {}) {
|
|
||||||
let installed = Boolean(options.installed)
|
|
||||||
const calls = []
|
|
||||||
const prepare = api.createFusePreparation({
|
|
||||||
platform: options.platform ?? 'linux', arch: 'x64',
|
|
||||||
readOsRelease: () => `ID=ubuntu\nVERSION_ID="${options.version ?? '24.04'}"\nPRETTY_NAME="Ubuntu"`,
|
|
||||||
confirm: async () => { calls.push('confirm'); return options.accept !== false },
|
|
||||||
run: async (command, args) => {
|
|
||||||
calls.push([command, ...args])
|
|
||||||
if (command === '/sbin/ldconfig') return { stdout: installed ? cache : '', stderr: '' }
|
|
||||||
if (command === '/usr/bin/apt-cache') return { stdout: options.noCandidate ? 'Candidate: (none)' : 'Candidate: 2.9.9', stderr: '' }
|
|
||||||
if (command === 'pkexec') {
|
|
||||||
if (options.fail) throw new Error('authentication cancelled')
|
|
||||||
installed = options.stillMissing !== true
|
|
||||||
return { stdout: '', stderr: '' }
|
|
||||||
}
|
|
||||||
throw new Error(`Unexpected command ${command}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { prepare, calls }
|
|
||||||
}
|
|
||||||
|
|
||||||
test('FUSE 3 and a foreign architecture do not satisfy FUSE 2', () => {
|
|
||||||
assert.equal(api.hasFuseLibrary(cache, 'x64'), true)
|
|
||||||
assert.equal(api.hasFuseLibrary(cache, 'arm64'), false)
|
|
||||||
assert.equal(api.hasFuseLibrary('libfuse3.so.3 (libc6,x86-64)', 'x64'), false)
|
|
||||||
})
|
|
||||||
test('existing library and non-Linux skip installation', async () => {
|
|
||||||
for (const options of [{ installed: true }, { platform: 'darwin' }]) {
|
|
||||||
const f = fixture(options)
|
|
||||||
await f.prepare()
|
|
||||||
assert.equal(f.calls.includes('confirm'), false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
test('Ubuntu versions choose the appropriate package and recheck installation', async () => {
|
|
||||||
for (const [version, pkg] of [['22.04', 'libfuse2'], ['24.04', 'libfuse2t64'], ['26.04', 'libfuse2t64']]) {
|
|
||||||
const f = fixture({ version })
|
|
||||||
await f.prepare()
|
|
||||||
assert.deepEqual(f.calls.find((call) => call[0] === 'pkexec'), ['pkexec', '/usr/bin/apt-get', 'install', '-y', '--no-remove', pkg])
|
|
||||||
assert.equal(f.calls.filter((call) => call[0] === '/sbin/ldconfig').length, 2)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
test('cancel does not install and can be retried', async () => {
|
|
||||||
const f = fixture({ accept: false })
|
|
||||||
await assert.rejects(f.prepare(), /취소/)
|
|
||||||
await assert.rejects(f.prepare(), /취소/)
|
|
||||||
assert.equal(f.calls.some((call) => call[0] === 'pkexec'), false)
|
|
||||||
})
|
|
||||||
test('missing candidate, failed authentication and failed postcheck reject', async () => {
|
|
||||||
for (const options of [{ noCandidate: true }, { fail: true }, { stillMissing: true }]) {
|
|
||||||
await assert.rejects(fixture(options).prepare())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
test('concurrent preparations share one prompt and installation', async () => {
|
|
||||||
const f = fixture()
|
|
||||||
await Promise.all([f.prepare(), f.prepare()])
|
|
||||||
assert.equal(f.calls.filter((call) => call === 'confirm').length, 1)
|
|
||||||
assert.equal(f.calls.filter((call) => call[0] === 'pkexec').length, 1)
|
|
||||||
})
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
export type FusePackage = 'libfuse2' | 'libfuse2t64'
|
|
||||||
|
|
||||||
export function parseOsRelease(content: string): Record<string, string> {
|
|
||||||
return Object.fromEntries(content.split('\n').flatMap((line) => {
|
|
||||||
const match = line.match(/^([A-Z_]+)=(.*)$/)
|
|
||||||
return match ? [[match[1], match[2].replace(/^(["'])(.*)\1$/, '$2')]] : []
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fusePackageCandidates(os: Record<string, string>): FusePackage[] {
|
|
||||||
const modern = (os.ID === 'ubuntu' && Number.parseFloat(os.VERSION_ID) >= 24.04)
|
|
||||||
|| (os.ID === 'debian' && Number.parseFloat(os.VERSION_ID) >= 13)
|
|
||||||
return modern ? ['libfuse2t64', 'libfuse2'] : ['libfuse2', 'libfuse2t64']
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasFuseLibrary(cache: string, arch: string): boolean {
|
|
||||||
const architecture = arch === 'x64' ? 'x86-64' : arch === 'arm64' ? 'AArch64' : null
|
|
||||||
return cache.split('\n').some((line) => /^\s*libfuse\.so\.2\s/.test(line)
|
|
||||||
&& (!architecture || line.includes(architecture)))
|
|
||||||
}
|
|
||||||
|
|
||||||
type Dependencies = {
|
|
||||||
platform: string
|
|
||||||
arch: string
|
|
||||||
readOsRelease: () => string
|
|
||||||
run: (command: string, args: string[], timeout?: number) => Promise<{ stdout: string; stderr: string }>
|
|
||||||
confirm: (details: string) => Promise<boolean>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFusePreparation(deps: Dependencies): () => Promise<void> {
|
|
||||||
let pending: Promise<void> | null = null
|
|
||||||
const ready = async (): Promise<boolean> => {
|
|
||||||
const result = await deps.run('/sbin/ldconfig', ['-p'], 10000)
|
|
||||||
return hasFuseLibrary(result.stdout, deps.arch)
|
|
||||||
}
|
|
||||||
const prepare = async (): Promise<void> => {
|
|
||||||
if (deps.platform !== 'linux' || await ready()) return
|
|
||||||
const os = parseOsRelease(deps.readOsRelease())
|
|
||||||
if (![os.ID, ...(os.ID_LIKE ?? '').split(/\s+/)].some((id) => id === 'ubuntu' || id === 'debian')) {
|
|
||||||
throw new Error('LMS 실행에 libfuse.so.2가 필요합니다. 이 운영체제에서는 자동 설치를 지원하지 않습니다. 배포판의 FUSE 2 라이브러리를 설치한 뒤 다시 실행하세요.')
|
|
||||||
}
|
|
||||||
let selected: FusePackage | undefined
|
|
||||||
for (const candidate of fusePackageCandidates(os)) {
|
|
||||||
const result = await deps.run('/usr/bin/apt-cache', ['policy', candidate], 10000)
|
|
||||||
if (/Candidate:\s*(?!\(none\))\S+/.test(result.stdout)) {
|
|
||||||
selected = candidate
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!selected) throw new Error('FUSE 2 설치 패키지를 찾지 못했습니다. 시스템 패키지 목록과 Ubuntu 저장소 설정을 확인한 뒤 다시 시도하세요.')
|
|
||||||
if (!await deps.confirm(`${os.PRETTY_NAME ?? os.ID}\n\nLMS 실행에 필요한 ${selected} 라이브러리가 없습니다. 설치를 누르면 관리자 인증 후 설치합니다.`)) {
|
|
||||||
throw new Error('필수 구성요소 설치를 취소했습니다. 다음 설치 또는 실행 시 다시 진행할 수 있습니다.')
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await deps.run('pkexec', ['/usr/bin/apt-get', 'install', '-y', '--no-remove', selected], 600000)
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`필수 구성요소 설치가 완료되지 않았습니다. 관리자 인증 취소, 네트워크 또는 패키지 관리자 잠금 상태를 확인하세요.\n${error instanceof Error ? error.message : String(error)}`)
|
|
||||||
}
|
|
||||||
if (!await ready()) throw new Error('설치 후에도 libfuse.so.2를 확인하지 못했습니다. 시스템 패키지 상태를 확인한 뒤 다시 시도하세요.')
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (!pending) pending = prepare().finally(() => { pending = null })
|
|
||||||
return pending
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+27
-172
@@ -6,7 +6,6 @@ import { get as httpsGet } from 'https'
|
|||||||
import { spawn, exec, execFile, ChildProcess } from 'child_process'
|
import { spawn, exec, execFile, ChildProcess } from 'child_process'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import { LmsWebBridge } from './lms-web-bridge'
|
import { LmsWebBridge } from './lms-web-bridge'
|
||||||
import { createFusePreparation } from './appImageFuse'
|
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
||||||
@@ -15,35 +14,6 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
let splash: BrowserWindow | null = null
|
let splash: BrowserWindow | null = null
|
||||||
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
|
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
|
||||||
const lmsWebBridge = new LmsWebBridge()
|
const lmsWebBridge = new LmsWebBridge()
|
||||||
const prepareLmsFuse = createFusePreparation({
|
|
||||||
platform: process.platform,
|
|
||||||
arch: process.arch,
|
|
||||||
readOsRelease: () => readFileSync('/etc/os-release', 'utf8'),
|
|
||||||
run: (command, args, timeout) => runCommand(command, args, timeout, { LC_ALL: 'C', LANG: 'C' }),
|
|
||||||
confirm: async (detail) => {
|
|
||||||
const options = {
|
|
||||||
type: 'info' as const,
|
|
||||||
title: 'LMS 실행 환경 준비',
|
|
||||||
message: '필수 구성요소를 설치하시겠습니까?',
|
|
||||||
detail,
|
|
||||||
buttons: ['설치', '취소'],
|
|
||||||
defaultId: 0,
|
|
||||||
cancelId: 1,
|
|
||||||
noLink: true,
|
|
||||||
}
|
|
||||||
const parent = BrowserWindow.getFocusedWindow()
|
|
||||||
const result = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options)
|
|
||||||
return result.response === 0
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function prepareAppRuntime(payload: { appName: string; execPath?: string; devAppPath?: string }): Promise<void> {
|
|
||||||
if (payload.appName.trim().toLowerCase() !== 'lms') return
|
|
||||||
const devPath = resolveDevAppPath(payload.devAppPath) ?? getDefaultDevAppPath(payload.appName)
|
|
||||||
if (is.dev && devPath && isRunnableDevAppPath(devPath)) return
|
|
||||||
const path = resolveExecPath(payload.execPath, payload.appName)
|
|
||||||
if (path?.toLowerCase().endsWith('.appimage')) await prepareLmsFuse()
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommandResult = {
|
type CommandResult = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -350,17 +320,12 @@ function downloadFile(
|
|||||||
|
|
||||||
const total = Number(response.headers['content-length'] ?? 0)
|
const total = Number(response.headers['content-length'] ?? 0)
|
||||||
let received = 0
|
let received = 0
|
||||||
let lastProgress = -1
|
|
||||||
const file = createWriteStream(tmpPath)
|
const file = createWriteStream(tmpPath)
|
||||||
|
|
||||||
response.on('data', (chunk: Buffer) => {
|
response.on('data', (chunk: Buffer) => {
|
||||||
received += chunk.length
|
received += chunk.length
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
const progress = Math.min(99, Math.round((received / total) * 100))
|
onProgress(Math.min(99, Math.round((received / total) * 100)))
|
||||||
if (progress !== lastProgress) {
|
|
||||||
lastProgress = progress
|
|
||||||
onProgress(progress)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -439,41 +404,18 @@ function createCleanChildEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|||||||
return env
|
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(
|
function stopChildApps(
|
||||||
appProcesses: Map<string, ChildProcess>,
|
appProcesses: Map<string, ChildProcess>,
|
||||||
appWindows: Map<string, BrowserWindow>,
|
appWindows: Map<string, BrowserWindow>,
|
||||||
): void {
|
): void {
|
||||||
for (const child of appProcesses.values()) terminateChildProcess(child)
|
for (const child of appProcesses.values()) {
|
||||||
|
if (!child.killed) {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!child.killed) child.kill('SIGKILL')
|
||||||
|
}, 1500).unref()
|
||||||
|
}
|
||||||
|
}
|
||||||
appProcesses.clear()
|
appProcesses.clear()
|
||||||
|
|
||||||
for (const win of appWindows.values()) {
|
for (const win of appWindows.values()) {
|
||||||
@@ -547,38 +489,6 @@ function tailscaleCommandEnv(loginServer: string): NodeJS.ProcessEnv | undefined
|
|||||||
return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined
|
return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLauncherOsUser(): string {
|
|
||||||
const user = (process.env.USER ?? process.env.LOGNAME ?? '').trim()
|
|
||||||
return /^[a-z_][a-z0-9_-]*\$?$/i.test(user) ? user : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureTailscaleOperator(): Promise<CommandResult> {
|
|
||||||
const operator = getLauncherOsUser()
|
|
||||||
if (!operator) {
|
|
||||||
return { ok: false, message: '현재 OS 사용자를 확인하지 못했습니다.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = ['set', `--operator=${operator}`]
|
|
||||||
try {
|
|
||||||
await runCommand('tailscale', args, 15000)
|
|
||||||
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
|
|
||||||
} catch (error) {
|
|
||||||
if (!isTailscalePermissionError(error)) {
|
|
||||||
return { ok: false, message: commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패') }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runCommand('pkexec', ['/usr/bin/tailscale', ...args], 30000)
|
|
||||||
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
|
|
||||||
} catch (pkexecError) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패')),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installTailscale(): Promise<CommandResult> {
|
async function installTailscale(): Promise<CommandResult> {
|
||||||
const installScript = [
|
const installScript = [
|
||||||
'set -e',
|
'set -e',
|
||||||
@@ -657,6 +567,13 @@ async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise<vo
|
|||||||
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
|
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetTailscaleSessionWithPkexec(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
const loggedOut = await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'logout'], 30000, commandEnv)
|
||||||
|
if (!loggedOut) {
|
||||||
|
await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'down'], 30000, commandEnv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
|
async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
|
||||||
const attempts: Array<[string, string[]]> = [
|
const attempts: Array<[string, string[]]> = [
|
||||||
['tailscale', ['status', '--json']],
|
['tailscale', ['status', '--json']],
|
||||||
@@ -776,24 +693,16 @@ async function loginTailscale(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isTailscalePermissionError(error)) {
|
if (isTailscalePermissionError(error)) {
|
||||||
try {
|
try {
|
||||||
|
if (forceReset) {
|
||||||
|
await resetTailscaleSessionWithPkexec(commandEnv)
|
||||||
|
}
|
||||||
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
|
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
|
||||||
await waitForTailscaleConnected()
|
await waitForTailscaleConnected()
|
||||||
|
return { ok: true, message: 'Tailscale 연결 완료' }
|
||||||
// Persist unprivileged access after the one privileged recovery login.
|
|
||||||
const operator = await ensureTailscaleOperator()
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
message: operator.ok
|
|
||||||
? 'Tailscale 연결 완료'
|
|
||||||
: 'Tailscale 연결 완료. 다음 연결을 위해 제어 권한을 다시 확인하세요.',
|
|
||||||
}
|
|
||||||
} catch (pkexecError) {
|
} catch (pkexecError) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: commandErrorMessage(
|
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다.')),
|
||||||
pkexecError,
|
|
||||||
commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다. 다시 연결해 주세요.'),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,23 +749,6 @@ 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 {
|
function createSplashWindow(): void {
|
||||||
splash = new BrowserWindow({
|
splash = new BrowserWindow({
|
||||||
width: 580,
|
width: 580,
|
||||||
@@ -907,9 +799,7 @@ function createWindow(): void {
|
|||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '../preload/index.js'),
|
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)
|
attachDevToolsShortcut(mainWindow)
|
||||||
@@ -925,10 +815,6 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}, 1500)
|
}, 1500)
|
||||||
})
|
})
|
||||||
mainWindow.on('close', () => {
|
|
||||||
lmsWebBridge.invalidateSessions()
|
|
||||||
stopChildApps(appProcesses, appWindows)
|
|
||||||
})
|
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
shell.openExternal(details.url)
|
shell.openExternal(details.url)
|
||||||
@@ -987,10 +873,6 @@ function createWindow(): void {
|
|||||||
const targetPath = resolveExecPath(payload.execPath, payload.appName)
|
const targetPath = resolveExecPath(payload.execPath, payload.appName)
|
||||||
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
|
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
|
||||||
|
|
||||||
if (payload.appName.trim().toLowerCase() === 'lms' && targetPath.toLowerCase().endsWith('.appimage')) {
|
|
||||||
await prepareLmsFuse()
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
|
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
|
||||||
event.sender.send('app-download-progress', {
|
event.sender.send('app-download-progress', {
|
||||||
appName: payload.appName,
|
appName: payload.appName,
|
||||||
@@ -1099,7 +981,6 @@ function createWindow(): void {
|
|||||||
})
|
})
|
||||||
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
||||||
ipcMain.handle('launcher-tailscale-install', () => installTailscale())
|
ipcMain.handle('launcher-tailscale-install', () => installTailscale())
|
||||||
ipcMain.handle('launcher-tailscale-ensure-operator', () => ensureTailscaleOperator())
|
|
||||||
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'launcher-tailscale-login',
|
'launcher-tailscale-login',
|
||||||
@@ -1107,7 +988,6 @@ function createWindow(): void {
|
|||||||
loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)),
|
loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)),
|
||||||
)
|
)
|
||||||
ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale())
|
ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale())
|
||||||
ipcMain.handle('launcher-tailscale-restart-service', () => restartTailscaleService())
|
|
||||||
|
|
||||||
const onAppClosed = (appName: string): void => {
|
const onAppClosed = (appName: string): void => {
|
||||||
mainWindow.webContents.send('app-window-closed', appName)
|
mainWindow.webContents.send('app-window-closed', appName)
|
||||||
@@ -1117,28 +997,7 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('launcher-end-user-session', () => {
|
ipcMain.on('launch-app', (_, payload: {
|
||||||
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.handle('prepare-app-runtime', async (_event, payload: { appName: string; execPath?: string; devAppPath?: string }) => {
|
|
||||||
await prepareAppRuntime(payload)
|
|
||||||
return { ok: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.on('launch-app', async (_, payload: {
|
|
||||||
appName: string
|
appName: string
|
||||||
url?: string
|
url?: string
|
||||||
width?: number
|
width?: number
|
||||||
@@ -1148,13 +1007,6 @@ function createWindow(): void {
|
|||||||
handoffCode?: string
|
handoffCode?: string
|
||||||
audienceApp?: string
|
audienceApp?: string
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
|
||||||
await prepareAppRuntime(payload)
|
|
||||||
} catch (error) {
|
|
||||||
mainWindow.webContents.send('app-window-closed', payload.appName)
|
|
||||||
dialog.showErrorBox('LMS 실행 환경 준비 실패', error instanceof Error ? error.message : String(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload
|
const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload
|
||||||
const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined
|
const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined
|
||||||
const authLaunchArgs = handoffCode
|
const authLaunchArgs = handoffCode
|
||||||
@@ -1203,9 +1055,12 @@ function createWindow(): void {
|
|||||||
if (spawnPath) {
|
if (spawnPath) {
|
||||||
const useDevLauncher = is.dev && spawnPath === resolvedDevAppPath
|
const useDevLauncher = is.dev && spawnPath === resolvedDevAppPath
|
||||||
const launchUrlArg = url ? [`--url=${encodeURIComponent(url)}`] : []
|
const launchUrlArg = url ? [`--url=${encodeURIComponent(url)}`] : []
|
||||||
|
const noSandboxArg = !useDevLauncher && process.platform === 'linux' && spawnPath.toLowerCase().endsWith('.appimage')
|
||||||
|
? ['--no-sandbox']
|
||||||
|
: []
|
||||||
const args = useDevLauncher
|
const args = useDevLauncher
|
||||||
? ['run', 'dev']
|
? ['run', 'dev']
|
||||||
: [...launchUrlArg, ...authLaunchArgs, ...(authProtocolUrl ? [authProtocolUrl] : [])]
|
: [...noSandboxArg, ...launchUrlArg, ...authLaunchArgs, ...(authProtocolUrl ? [authProtocolUrl] : [])]
|
||||||
const execBin = useDevLauncher ? 'npm' : spawnPath
|
const execBin = useDevLauncher ? 'npm' : spawnPath
|
||||||
|
|
||||||
const childEnv = createCleanChildEnv()
|
const childEnv = createCleanChildEnv()
|
||||||
|
|||||||
+20
-396
@@ -14,8 +14,6 @@ type CourseModule = {
|
|||||||
folderName: string
|
folderName: string
|
||||||
category?: string
|
category?: string
|
||||||
sortOrder?: number
|
sortOrder?: number
|
||||||
defaultParameters?: string[] | null
|
|
||||||
dockerImage?: string | null
|
|
||||||
templateRepositoryUrl?: string | null
|
templateRepositoryUrl?: string | null
|
||||||
studentRepositoryUrl?: string | null
|
studentRepositoryUrl?: string | null
|
||||||
}
|
}
|
||||||
@@ -49,26 +47,10 @@ type RuntimeImageArtifact = {
|
|||||||
required?: boolean | null
|
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 BRIDGE_SESSION_TTL_MS = 30 * 60 * 1000
|
||||||
const DEFAULT_WORKSPACE_ROOT = '~/autonomy-studio/workspaces'
|
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 AUTOWARE_IMAGE = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda'
|
||||||
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 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 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 skippedDirectoryNames = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker'])
|
||||||
const studioGitMetadataFile = '.autonomy-studio-git.json'
|
const studioGitMetadataFile = '.autonomy-studio-git.json'
|
||||||
const workspaceGitIgnoreLines = [
|
const workspaceGitIgnoreLines = [
|
||||||
@@ -132,39 +114,6 @@ function expandWorkspaceRoot(value?: string): string {
|
|||||||
return path
|
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 {
|
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)
|
return typeof value === 'string' && /^\[[A-Za-z0-9_.-]+\][A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+_ws$/.test(value)
|
||||||
}
|
}
|
||||||
@@ -176,45 +125,6 @@ function containerName(folderName: string): string | null {
|
|||||||
return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(name) ? name : null
|
return /^[A-Za-z0-9_.-]+-[A-Za-z0-9_.-]+_v[A-Za-z0-9_.-]+$/.test(name) ? name : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNav2ModuleFolder(folderName: string): boolean {
|
|
||||||
return /^\[lms-nav2-[^\]]+\].+_v[^/]+_ws$/.test(folderName)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocalNav2RuntimeImage(module: CourseModule, body: Record<string, unknown>, rosParams: string): string {
|
|
||||||
const configured = module.dockerImage?.trim() ?? ''
|
|
||||||
if (configured && /^[A-Za-z0-9._/:@-]+$/.test(configured)) return configured
|
|
||||||
|
|
||||||
const gazeboModule = /^\[lms-nav2-gazebo-/i.test(module.folderName)
|
|
||||||
|| (/lms-nav2-mkmini/i.test(module.folderName) && /(?:^|\s)simulator:=gazebo(?:\s|$)/.test(rosParams))
|
|
||||||
const runtimeImages = Array.isArray(body.runtimeImages) ? body.runtimeImages : []
|
|
||||||
const matched = runtimeImages.find((value) => {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
||||||
const image = (value as RuntimeImageArtifact).image?.trim() ?? ''
|
|
||||||
return gazeboModule
|
|
||||||
? /nav2.*gazebo|gazebo.*nav2/i.test(image)
|
|
||||||
: /nav2/i.test(image) && !/gazebo|ros-bridge/i.test(image)
|
|
||||||
}) as RuntimeImageArtifact | undefined
|
|
||||||
|
|
||||||
return matched?.image?.trim() || (gazeboModule ? 'future-nav2-gazebo:humble' : 'future-nav2:humble')
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
function validRepositoryUrl(value: unknown): value is string {
|
||||||
if (typeof value !== 'string' || !value.trim()) return false
|
if (typeof value !== 'string' || !value.trim()) return false
|
||||||
return /^(https?:\/\/|ssh:\/\/|git@)[A-Za-z0-9_.:@/~-]+(?:\.git)?$/i.test(value.trim())
|
return /^(https?:\/\/|ssh:\/\/|git@)[A-Za-z0-9_.:@/~-]+(?:\.git)?$/i.test(value.trim())
|
||||||
@@ -341,11 +251,9 @@ function buildRemoteWorkspaceShellCommand(moduleName: string | undefined, remote
|
|||||||
const promptLabel = (moduleName?.trim() || basename(remotePath)).replace(/[^a-zA-Z0-9_.-]+/g, '-')
|
const promptLabel = (moduleName?.trim() || basename(remotePath)).replace(/[^a-zA-Z0-9_.-]+/g, '-')
|
||||||
const safeCols = terminalSize(cols, 80, 20, 300)
|
const safeCols = terminalSize(cols, 80, 20, 300)
|
||||||
const safeRows = terminalSize(rows, 24, 5, 120)
|
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 = [
|
const initCommands = [
|
||||||
workspaceRootAssignment,
|
`MODULE_WORKSPACE_ROOT=${quoteShell(remotePath)}`,
|
||||||
|
'case "$MODULE_WORKSPACE_ROOT" in ~/*) MODULE_WORKSPACE_ROOT="$HOME/${MODULE_WORKSPACE_ROOT#~/}";; esac',
|
||||||
`export MODULE_PROMPT_LABEL=${quoteShell(promptLabel)}`,
|
`export MODULE_PROMPT_LABEL=${quoteShell(promptLabel)}`,
|
||||||
`stty cols ${safeCols} rows ${safeRows}`,
|
`stty cols ${safeCols} rows ${safeRows}`,
|
||||||
'cd "$MODULE_WORKSPACE_ROOT"',
|
'cd "$MODULE_WORKSPACE_ROOT"',
|
||||||
@@ -455,7 +363,6 @@ export class LmsWebBridge {
|
|||||||
private port = 0
|
private port = 0
|
||||||
private sessions = new Map<string, BridgeSession>()
|
private sessions = new Map<string, BridgeSession>()
|
||||||
private terminals = new Map<string, TerminalSession>()
|
private terminals = new Map<string, TerminalSession>()
|
||||||
private sessionEventStreams = new Map<string, Set<ServerResponse>>()
|
|
||||||
|
|
||||||
async createSession(origin: string) {
|
async createSession(origin: string) {
|
||||||
await this.ensureStarted()
|
await this.ensureStarted()
|
||||||
@@ -466,22 +373,9 @@ export class LmsWebBridge {
|
|||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
if (!this.server) return
|
if (!this.server) return
|
||||||
this.invalidateSessions()
|
|
||||||
await new Promise<void>((resolvePromise) => this.server?.close(() => resolvePromise()))
|
await new Promise<void>((resolvePromise) => this.server?.close(() => resolvePromise()))
|
||||||
this.server = null
|
this.server = null
|
||||||
this.port = 0
|
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()
|
this.sessions.clear()
|
||||||
for (const terminal of this.terminals.values()) terminal.process.kill()
|
for (const terminal of this.terminals.values()) terminal.process.kill()
|
||||||
this.terminals.clear()
|
this.terminals.clear()
|
||||||
@@ -543,32 +437,6 @@ export class LmsWebBridge {
|
|||||||
return
|
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('지원하지 않는 요청입니다.')
|
if (request.method !== 'POST') throw new Error('지원하지 않는 요청입니다.')
|
||||||
const body = await readBody(request)
|
const body = await readBody(request)
|
||||||
if (path === '/api/modules/list') {
|
if (path === '/api/modules/list') {
|
||||||
@@ -611,10 +479,6 @@ export class LmsWebBridge {
|
|||||||
respond(response, 200, await this.readModuleFiles(body), session.origin)
|
respond(response, 200, await this.readModuleFiles(body), session.origin)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (path === '/api/files/image') {
|
|
||||||
respond(response, 200, await this.readModuleImage(body), session.origin)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (path === '/api/files/create') {
|
if (path === '/api/files/create') {
|
||||||
respond(response, 200, await this.createModuleFile(body), session.origin)
|
respond(response, 200, await this.createModuleFile(body), session.origin)
|
||||||
return
|
return
|
||||||
@@ -635,14 +499,6 @@ export class LmsWebBridge {
|
|||||||
respond(response, 200, await this.deleteModuleFile(body), session.origin)
|
respond(response, 200, await this.deleteModuleFile(body), session.origin)
|
||||||
return
|
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') {
|
if (path === '/api/git/status') {
|
||||||
respond(response, 200, await this.gitStatus(body), session.origin)
|
respond(response, 200, await this.gitStatus(body), session.origin)
|
||||||
return
|
return
|
||||||
@@ -691,10 +547,6 @@ export class LmsWebBridge {
|
|||||||
respond(response, 200, await this.readRemoteFiles(body), session.origin)
|
respond(response, 200, await this.readRemoteFiles(body), session.origin)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (path === '/api/remote/files/image') {
|
|
||||||
respond(response, 200, await this.readRemoteImage(body), session.origin)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (path === '/api/remote/files/create') {
|
if (path === '/api/remote/files/create') {
|
||||||
respond(response, 200, await this.remoteFileMutation(body, 'create'), session.origin)
|
respond(response, 200, await this.remoteFileMutation(body, 'create'), session.origin)
|
||||||
return
|
return
|
||||||
@@ -751,14 +603,6 @@ export class LmsWebBridge {
|
|||||||
respond(response, 200, await this.stopRemoteModule(body), session.origin)
|
respond(response, 200, await this.stopRemoteModule(body), session.origin)
|
||||||
return
|
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') {
|
if (path === '/api/remote/modules/logs') {
|
||||||
respond(response, 200, await this.remoteModuleLogs(body), session.origin)
|
respond(response, 200, await this.remoteModuleLogs(body), session.origin)
|
||||||
return
|
return
|
||||||
@@ -801,24 +645,6 @@ export class LmsWebBridge {
|
|||||||
return value
|
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>) {
|
private moduleRoot(body: Record<string, unknown>) {
|
||||||
const module = this.moduleFromBody(body)
|
const module = this.moduleFromBody(body)
|
||||||
const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
|
const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
|
||||||
@@ -851,16 +677,7 @@ export class LmsWebBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ssh(target: RemoteWorkspaceTarget, script: string, timeoutMs = 30_000) {
|
private ssh(target: RemoteWorkspaceTarget, script: string, timeoutMs = 30_000) {
|
||||||
return command('ssh', [
|
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)
|
||||||
'-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() {
|
private async dockerStatuses() {
|
||||||
@@ -952,6 +769,7 @@ export class LmsWebBridge {
|
|||||||
const module = this.moduleFromBody(body)
|
const module = this.moduleFromBody(body)
|
||||||
const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
|
const root = expandWorkspaceRoot(typeof body.workspaceRoot === 'string' ? body.workspaceRoot : undefined)
|
||||||
const target = resolve(root, module.folderName)
|
const target = resolve(root, module.folderName)
|
||||||
|
const runtimeConfigRoot = resolve(homedir(), 'autonomy-studio', 'runtime', 'config')
|
||||||
if (!existsSync(target)) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
if (!existsSync(target)) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||||
const container = containerName(module.folderName)
|
const container = containerName(module.folderName)
|
||||||
if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.')
|
if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.')
|
||||||
@@ -961,23 +779,6 @@ export class LmsWebBridge {
|
|||||||
const rosParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : ''
|
const rosParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : ''
|
||||||
const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0'
|
const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0'
|
||||||
const gpuEnabled = body.gpuEnabled === true
|
const gpuEnabled = body.gpuEnabled === true
|
||||||
if (isNav2ModuleFolder(module.folderName)) {
|
|
||||||
const runtimeScript = resolve(homedir(), 'autonomy-studio', 'runtime', 'nav2', 'docker', 'docker_run_nav2.sh')
|
|
||||||
if (!existsSync(runtimeScript)) throw new Error(`NAV2 런타임 스크립트를 찾을 수 없습니다: ${runtimeScript}`)
|
|
||||||
await command('bash', [
|
|
||||||
runtimeScript,
|
|
||||||
container,
|
|
||||||
root,
|
|
||||||
module.folderName,
|
|
||||||
resolveLocalNav2RuntimeImage(module, body, rosParams),
|
|
||||||
rosDomainId,
|
|
||||||
gpuEnabled ? 'true' : 'false',
|
|
||||||
...rosParams.split(/\s+/).filter(Boolean),
|
|
||||||
], undefined, 45_000)
|
|
||||||
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeConfigRoot = resolve(homedir(), 'autonomy-studio', 'runtime', 'config')
|
|
||||||
await mkdir(runtimeConfigRoot, { recursive: true })
|
await mkdir(runtimeConfigRoot, { recursive: true })
|
||||||
const args = ['run', '-d', '--name', container, '--network', 'host', '-v', `${target}:/autoware:rw`, '-v', `${runtimeConfigRoot}:/autoware/config:rw`, '-v', '/etc/localtime:/etc/localtime:ro', '-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-e', 'RCUTILS_COLORIZED_OUTPUT=1', '-e', 'ROS_LOCALHOST_ONLY=1']
|
const args = ['run', '-d', '--name', container, '--network', 'host', '-v', `${target}:/autoware:rw`, '-v', `${runtimeConfigRoot}:/autoware/config:rw`, '-v', '/etc/localtime:/etc/localtime:ro', '-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-e', 'RCUTILS_COLORIZED_OUTPUT=1', '-e', 'ROS_LOCALHOST_ONLY=1']
|
||||||
if (process.env.DISPLAY) args.push('-e', `DISPLAY=${process.env.DISPLAY}`, '-v', '/tmp/.X11-unix:/tmp/.X11-unix')
|
if (process.env.DISPLAY) args.push('-e', `DISPLAY=${process.env.DISPLAY}`, '-v', '/tmp/.X11-unix:/tmp/.X11-unix')
|
||||||
@@ -995,19 +796,6 @@ export class LmsWebBridge {
|
|||||||
const container = containerName(module.folderName)
|
const container = containerName(module.folderName)
|
||||||
if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.')
|
if (!container) throw new Error('모듈 컨테이너 이름이 올바르지 않습니다.')
|
||||||
const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0'
|
const rosDomainId = typeof body.rosDomainId === 'number' || typeof body.rosDomainId === 'string' ? String(body.rosDomainId).replace(/[^0-9]/g, '') || '0' : '0'
|
||||||
if (isNav2ModuleFolder(module.folderName)) {
|
|
||||||
const runtimeScript = resolve(homedir(), 'autonomy-studio', 'runtime', 'nav2', 'docker', 'docker_build_nav2.sh')
|
|
||||||
if (!existsSync(runtimeScript)) throw new Error(`NAV2 빌드 스크립트를 찾을 수 없습니다: ${runtimeScript}`)
|
|
||||||
const rosParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : ''
|
|
||||||
await command('bash', [
|
|
||||||
runtimeScript,
|
|
||||||
root,
|
|
||||||
module.folderName,
|
|
||||||
resolveLocalNav2RuntimeImage(module, body, rosParams),
|
|
||||||
rosDomainId,
|
|
||||||
], undefined, 900_000)
|
|
||||||
return { ok: true, message: '빌드가 완료되었습니다.' }
|
|
||||||
}
|
|
||||||
await command('docker', [
|
await command('docker', [
|
||||||
'run', '--rm', '--name', `${container}-build`, '--volume', `${target}:/autoware:rw`, '-u', 'root', '--net=host',
|
'run', '--rm', '--name', `${container}-build`, '--volume', `${target}:/autoware:rw`, '-u', 'root', '--net=host',
|
||||||
'-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-v', '/etc/localtime:/etc/localtime:ro', '--entrypoint', '/bin/bash', AUTOWARE_IMAGE,
|
'-e', `ROS_DOMAIN_ID=${rosDomainId}`, '-v', '/etc/localtime:/etc/localtime:ro', '--entrypoint', '/bin/bash', AUTOWARE_IMAGE,
|
||||||
@@ -1099,25 +887,6 @@ export class LmsWebBridge {
|
|||||||
return { root: basename(target), absoluteRoot: target, files, folders }
|
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>) {
|
private async createModuleFile(body: Record<string, unknown>) {
|
||||||
const { absolutePath } = this.moduleFilePath(body, 'path')
|
const { absolutePath } = this.moduleFilePath(body, 'path')
|
||||||
const kind = body.kind
|
const kind = body.kind
|
||||||
@@ -1364,11 +1133,7 @@ export class LmsWebBridge {
|
|||||||
private remoteModule(body: Record<string, unknown>) {
|
private remoteModule(body: Record<string, unknown>) {
|
||||||
const target = this.remoteTarget(body)
|
const target = this.remoteTarget(body)
|
||||||
const module = this.moduleFromBody(body)
|
const module = this.moduleFromBody(body)
|
||||||
const workspaceRoot = target.workspaceRoot ?? ''
|
const rootScript = `ROOT=${quoteShell(target.workspaceRoot ?? '')}\ncase "$ROOT" in ~/*) ROOT="$HOME/${'$'}{ROOT#~/}";; esac\nMODULE="$ROOT/${module.folderName}"\n`
|
||||||
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 }
|
return { target, module, rootScript }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1403,22 +1168,6 @@ done`
|
|||||||
return { root: module.folderName, absoluteRoot: null, files, folders }
|
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') {
|
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 from = action === 'copy' || action === 'rename' ? this.remoteFilePath(body, 'fromPath') : null
|
||||||
const target = from ?? this.remoteFilePath(body, 'path')
|
const target = from ?? this.remoteFilePath(body, 'path')
|
||||||
@@ -1467,7 +1216,7 @@ done`
|
|||||||
const authorName = typeof body.authorName === 'string' && body.authorName.trim() ? body.authorName.trim() : 'Autonomy Studio LMS Bot'
|
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 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 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)}`, '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')
|
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')
|
||||||
return { ok: true, message: '제출되었습니다.', commit: parseGitCommits(await this.ssh(target, script, 150_000))[0] }
|
return { ok: true, message: '제출되었습니다.', commit: parseGitCommits(await this.ssh(target, script, 150_000))[0] }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||||
@@ -1519,17 +1268,7 @@ done`
|
|||||||
const cols = terminalSize(body.cols, 80, 20, 300)
|
const cols = terminalSize(body.cols, 80, 20, 300)
|
||||||
const rows = terminalSize(body.rows, 24, 5, 120)
|
const rows = terminalSize(body.rows, 24, 5, 120)
|
||||||
const shell = buildRemoteWorkspaceShellCommand(moduleName, remotePath, cols, rows)
|
const shell = buildRemoteWorkspaceShellCommand(moduleName, remotePath, cols, rows)
|
||||||
const shellProcess = pty.spawn('ssh', [
|
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], {
|
||||||
'-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',
|
name: 'xterm-256color',
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
@@ -1551,11 +1290,8 @@ done`
|
|||||||
if (!terminal || terminal.exited) return { ok: false }
|
if (!terminal || terminal.exited) return { ok: false }
|
||||||
const target = this.remoteTarget(body)
|
const target = this.remoteTarget(body)
|
||||||
const module = this.moduleFromBody({ module: { name: body.moduleName, folderName: body.folderName } })
|
const module = this.moduleFromBody({ module: { name: body.moduleName, folderName: body.folderName } })
|
||||||
const root = (target.workspaceRoot ?? '').replace(/\/+$/, '')
|
const path = `${(target.workspaceRoot ?? '').replace(/\/+$/, '')}/${module.folderName}`
|
||||||
const path = root.startsWith('~/')
|
terminal.process.write(`cd ${quoteShell(path)}\n`)
|
||||||
? `"$HOME/${root.slice(2)}/${module.folderName}"`
|
|
||||||
: quoteShell(`${root}/${module.folderName}`)
|
|
||||||
terminal.process.write(`cd ${path}\n`)
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1601,66 +1337,24 @@ done`
|
|||||||
return [name, { running: /^Up\b/i.test(status), status }]
|
return [name, { running: /^Up\b/i.test(status), status }]
|
||||||
}))
|
}))
|
||||||
const root = target.workspaceRoot ?? ''
|
const root = target.workspaceRoot ?? ''
|
||||||
const modulesToCheck = requested.flatMap((raw, index) => {
|
const modules = await Promise.all(requested.map(async (raw, index) => {
|
||||||
const module = raw as CourseModule
|
const module = raw as CourseModule
|
||||||
return module?.name && validFolderName(module.folderName) ? [{ module, index }] : []
|
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'
|
||||||
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 container = containerName(module.folderName)
|
||||||
const status = container ? docker.get(container) : undefined
|
const status = container ? docker.get(container) : undefined
|
||||||
return {
|
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) : '-' }
|
||||||
...module,
|
}))
|
||||||
sortOrder: Number(module.sortOrder) || index + 1,
|
return { workspaceRoot: root, modules: modules.filter(Boolean) }
|
||||||
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>) {
|
private async installRemoteModule(body: Record<string, unknown>) {
|
||||||
const { target, module, rootScript } = this.remoteModule(body)
|
const { target, module, rootScript } = this.remoteModule(body)
|
||||||
if (!validRepositoryUrl(module.templateRepositoryUrl)) throw new Error('템플릿 저장소 주소가 필요합니다.')
|
if (!validRepositoryUrl(module.templateRepositoryUrl)) throw new Error('템플릿 저장소 주소가 필요합니다.')
|
||||||
const studentUrl = validRepositoryUrl(module.studentRepositoryUrl) ? module.studentRepositoryUrl.trim() : ''
|
const studentUrl = validRepositoryUrl(module.studentRepositoryUrl) ? module.studentRepositoryUrl.trim() : ''
|
||||||
if (!studentUrl) throw new Error('학생 저장소 주소가 필요합니다.')
|
const script = [rootScript, 'mkdir -p "$ROOT"', '[ -d "$MODULE/.git" ] && exit 0', `git clone ${quoteShell(studentUrl || module.templateRepositoryUrl.trim())} "$MODULE"`].join('\n')
|
||||||
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)
|
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>) {
|
private async stopRemoteModule(body: Record<string, unknown>) {
|
||||||
@@ -1675,61 +1369,6 @@ done`
|
|||||||
return { ok: true, message: buildStatus.trim() && !status.trim() ? '빌드를 중지했습니다.' : '중지했습니다.' }
|
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>) {
|
private async startRemoteModule(body: Record<string, unknown>) {
|
||||||
const { target, module, rootScript } = this.remoteModule(body)
|
const { target, module, rootScript } = this.remoteModule(body)
|
||||||
const container = containerName(module.folderName)
|
const container = containerName(module.folderName)
|
||||||
@@ -1738,13 +1377,6 @@ done`
|
|||||||
const requestedParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : null
|
const requestedParams = typeof body.rosParams === 'string' ? body.rosParams.trim() : null
|
||||||
const gpuEnabled = body.gpuEnabled === true
|
const gpuEnabled = body.gpuEnabled === true
|
||||||
const buildOnly = body.buildOnly === 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 ? 'future-nav2-gazebo: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 : [])
|
const runtimeImages = (Array.isArray(body.runtimeImages) ? body.runtimeImages : [])
|
||||||
.filter((value): value is RuntimeImageArtifact => Boolean(value && typeof value === 'object'))
|
.filter((value): value is RuntimeImageArtifact => Boolean(value && typeof value === 'object'))
|
||||||
.map((value) => ({
|
.map((value) => ({
|
||||||
@@ -1797,19 +1429,11 @@ done`
|
|||||||
? [
|
? [
|
||||||
`BUILD_CONTAINER=${quoteShell(`${container}-build`)}`,
|
`BUILD_CONTAINER=${quoteShell(`${container}-build`)}`,
|
||||||
'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true',
|
'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true',
|
||||||
nav2Launch
|
`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'`,
|
||||||
? `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"',
|
'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`,
|
`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`,
|
||||||
]),
|
|
||||||
]),
|
]),
|
||||||
].filter(Boolean).join('\n'), 900_000)
|
].filter(Boolean).join('\n'), 900_000)
|
||||||
return { ok: true, message: buildOnly ? '빌드가 완료되었습니다.' : '실행 요청을 보냈습니다.' }
|
return { ok: true, message: buildOnly ? '빌드가 완료되었습니다.' : '실행 요청을 보냈습니다.' }
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ const appWindowMeta = { isAppWindow, appName, url: appUrl }
|
|||||||
const launcherTailscale = {
|
const launcherTailscale = {
|
||||||
status: () => ipcRenderer.invoke('launcher-tailscale-status'),
|
status: () => ipcRenderer.invoke('launcher-tailscale-status'),
|
||||||
install: () => ipcRenderer.invoke('launcher-tailscale-install'),
|
install: () => ipcRenderer.invoke('launcher-tailscale-install'),
|
||||||
ensureOperator: () => ipcRenderer.invoke('launcher-tailscale-ensure-operator'),
|
|
||||||
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
|
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
|
||||||
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
|
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
|
||||||
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
|
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
|
||||||
restartService: () => ipcRenderer.invoke('launcher-tailscale-restart-service'),
|
|
||||||
}
|
}
|
||||||
const launcherSshKey = {
|
const launcherSshKey = {
|
||||||
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
|
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
|
||||||
@@ -31,9 +29,6 @@ const launcherAuth = {
|
|||||||
return () => ipcRenderer.removeListener('google-oauth-callback', listener)
|
return () => ipcRenderer.removeListener('google-oauth-callback', listener)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const launcherSession = {
|
|
||||||
endUserSession: () => ipcRenderer.invoke('launcher-end-user-session'),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
@@ -42,7 +37,6 @@ if (process.contextIsolated) {
|
|||||||
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
|
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
|
||||||
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
|
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
|
||||||
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
||||||
contextBridge.exposeInMainWorld('launcherSession', launcherSession)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -57,6 +51,4 @@ if (process.contextIsolated) {
|
|||||||
window.launcherSshKey = launcherSshKey
|
window.launcherSshKey = launcherSshKey
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.launcherAuth = launcherAuth
|
window.launcherAuth = launcherAuth
|
||||||
// @ts-ignore
|
|
||||||
window.launcherSession = launcherSession
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-34
@@ -1,14 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import AppWindowShell from './pages/AppWindowShell'
|
import AppWindowShell from './pages/AppWindowShell'
|
||||||
import type { AuthSession } from './pages/LoginForm'
|
import type { AuthSession } from './pages/LoginForm'
|
||||||
import {
|
import { refreshLauncherSession } from './auth/sessionRefresh'
|
||||||
blockLauncherSessionRefresh,
|
|
||||||
refreshLauncherSession,
|
|
||||||
resetLauncherSessionRefresh,
|
|
||||||
} from './auth/sessionRefresh'
|
|
||||||
|
|
||||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
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(/\/+$/, '')
|
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||||
@@ -23,7 +19,6 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const [sessionExpiredOpen, setSessionExpiredOpen] = useState(false)
|
const [sessionExpiredOpen, setSessionExpiredOpen] = useState(false)
|
||||||
const sessionExpiredRef = useRef(false)
|
|
||||||
|
|
||||||
const meta = window.appWindowMeta
|
const meta = window.appWindowMeta
|
||||||
if (meta?.isAppWindow) {
|
if (meta?.isAppWindow) {
|
||||||
@@ -31,8 +26,6 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSessionUpdate = useCallback((session: AuthSession): void => {
|
const handleSessionUpdate = useCallback((session: AuthSession): void => {
|
||||||
if (sessionExpiredRef.current) return
|
|
||||||
|
|
||||||
const nextSession: AuthSession = {
|
const nextSession: AuthSession = {
|
||||||
...session,
|
...session,
|
||||||
user: {
|
user: {
|
||||||
@@ -58,19 +51,14 @@ function App(): React.JSX.Element {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLogout = useCallback((): void => {
|
const handleLogout = useCallback((): void => {
|
||||||
blockLauncherSessionRefresh()
|
|
||||||
window.launcherSession?.endUserSession().catch(() => undefined)
|
|
||||||
requestLogout(authSession?.accessToken)
|
requestLogout(authSession?.accessToken)
|
||||||
|
window.launcherTailscale?.logout().catch(() => undefined)
|
||||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
setAuthSession(null)
|
setAuthSession(null)
|
||||||
}, [authSession?.accessToken, requestLogout])
|
}, [authSession?.accessToken, requestLogout])
|
||||||
|
|
||||||
const handleSessionExpired = useCallback((): void => {
|
const handleSessionExpired = useCallback((): void => {
|
||||||
if (sessionExpiredRef.current) return
|
|
||||||
sessionExpiredRef.current = true
|
|
||||||
blockLauncherSessionRefresh()
|
|
||||||
window.launcherSession?.endUserSession().catch(() => undefined)
|
|
||||||
setSessionExpiredOpen(true)
|
setSessionExpiredOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -81,19 +69,28 @@ function App(): React.JSX.Element {
|
|||||||
setAuthSession(null)
|
setAuthSession(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLogin = useCallback((session: AuthSession): void => {
|
|
||||||
sessionExpiredRef.current = false
|
useEffect(() => {
|
||||||
resetLauncherSessionRefresh()
|
if (!authSession?.accessToken) return
|
||||||
setSessionExpiredOpen(false)
|
|
||||||
setAuthSession(session)
|
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])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession?.accessToken) return
|
if (!authSession?.accessToken) return
|
||||||
|
|
||||||
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
|
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
|
||||||
let timer: number | null = null
|
const timer = window.setTimeout(() => {
|
||||||
const refresh = (): void => {
|
|
||||||
refreshLauncherSession()
|
refreshLauncherSession()
|
||||||
.then((session) => {
|
.then((session) => {
|
||||||
if (session?.accessToken) {
|
if (session?.accessToken) {
|
||||||
@@ -102,25 +99,19 @@ function App(): React.JSX.Element {
|
|||||||
handleSessionExpired()
|
handleSessionExpired()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch(() => {
|
||||||
console.warn('[launcher] session refresh failed; retrying in 30 seconds', error)
|
handleSessionExpired()
|
||||||
timer = window.setTimeout(refresh, 30_000)
|
|
||||||
})
|
})
|
||||||
}
|
}, refreshDelayMs)
|
||||||
|
|
||||||
timer = window.setTimeout(refresh, refreshDelayMs)
|
return () => window.clearTimeout(timer)
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timer !== null) window.clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [authSession, handleSessionExpired, handleSessionUpdate])
|
}, [authSession, handleSessionExpired, handleSessionUpdate])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{authSession
|
{authSession
|
||||||
&& !sessionExpiredOpen
|
|
||||||
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} onSessionExpired={handleSessionExpired} />
|
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} onSessionExpired={handleSessionExpired} />
|
||||||
: <Login onLogin={handleLogin} />}
|
: <Login onLogin={setAuthSession} />}
|
||||||
{sessionExpiredOpen && createPortal(
|
{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]">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -355,7 +355,6 @@ body,
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 7px;
|
|
||||||
padding: 7px 16px;
|
padding: 7px 16px;
|
||||||
min-width: 78px;
|
min-width: 78px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
|||||||
@@ -3,32 +3,16 @@ import type { AuthSession } from '../pages/LoginForm'
|
|||||||
const launcherApiBaseUrl = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
const launcherApiBaseUrl = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||||
|
|
||||||
let pendingRefresh: Promise<AuthSession | null> | null = null
|
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> {
|
export function refreshLauncherSession(): Promise<AuthSession | null> {
|
||||||
if (refreshBlocked) return Promise.resolve(null)
|
|
||||||
if (pendingRefresh) return pendingRefresh
|
if (pendingRefresh) return pendingRefresh
|
||||||
|
|
||||||
pendingRefresh = fetch(`${launcherApiBaseUrl}/api/auth/refresh`, {
|
pendingRefresh = fetch(`${launcherApiBaseUrl}/api/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => response.ok ? response.json() as Promise<AuthSession> : null)
|
||||||
if (response.ok) return response.json() as Promise<AuthSession>
|
.catch(() => null)
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
refreshBlocked = true
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
throw new Error(`세션 갱신 요청에 실패했습니다. (${response.status})`)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
pendingRefresh = null
|
pendingRefresh = null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,17 +21,12 @@ import {
|
|||||||
MonitorCog,
|
MonitorCog,
|
||||||
Palette,
|
Palette,
|
||||||
Info,
|
Info,
|
||||||
Network,
|
Network
|
||||||
Copy,
|
|
||||||
Check
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LauncherApp } from '../config/apps'
|
import type { LauncherApp } from '../config/apps'
|
||||||
|
|
||||||
const ICON_SIZE = 14
|
const ICON_SIZE = 14
|
||||||
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
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> = {
|
const APP_ICONS: Record<string, React.ReactNode> = {
|
||||||
code: <Code2 size={ICON_SIZE} />,
|
code: <Code2 size={ICON_SIZE} />,
|
||||||
@@ -87,7 +82,7 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserModal = 'profile' | 'settings' | 'platforms' | null
|
type UserModal = 'profile' | 'settings' | 'platforms' | null
|
||||||
type SettingsTabKey = 'general' | 'remote' | 'theme' | 'info'
|
type SettingsTabKey = 'general' | 'theme' | 'info'
|
||||||
type VpnPlatformPeer = {
|
type VpnPlatformPeer = {
|
||||||
id: string
|
id: string
|
||||||
hostName: string
|
hostName: string
|
||||||
@@ -424,7 +419,6 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
|
|||||||
launcherUpdating={launcherUpdating}
|
launcherUpdating={launcherUpdating}
|
||||||
launcherUpdateMessage={launcherUpdateMessage}
|
launcherUpdateMessage={launcherUpdateMessage}
|
||||||
onLauncherUpdate={onLauncherUpdate}
|
onLauncherUpdate={onLauncherUpdate}
|
||||||
onTailscaleRetry={onTailscaleRetry}
|
|
||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -444,8 +438,6 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
|
|||||||
const [registrationNow, setRegistrationNow] = useState(() => Date.now())
|
const [registrationNow, setRegistrationNow] = useState(() => Date.now())
|
||||||
const [registrationLoading, setRegistrationLoading] = useState(false)
|
const [registrationLoading, setRegistrationLoading] = useState(false)
|
||||||
const [authorizationError, setAuthorizationError] = useState('')
|
const [authorizationError, setAuthorizationError] = useState('')
|
||||||
const [registrationGuideOpen, setRegistrationGuideOpen] = useState(false)
|
|
||||||
const [registrationGuideCopied, setRegistrationGuideCopied] = useState(false)
|
|
||||||
|
|
||||||
const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
|
const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -598,16 +590,6 @@ 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 (
|
return (
|
||||||
<ModalShell
|
<ModalShell
|
||||||
title="플랫폼 관리"
|
title="플랫폼 관리"
|
||||||
@@ -656,16 +638,16 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
|
|||||||
key={platform.id}
|
key={platform.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedId(platform.id)}
|
onClick={() => setSelectedId(platform.id)}
|
||||||
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 ${
|
className={`flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||||
active
|
active
|
||||||
? 'border-[rgba(103,232,249,0.42)] bg-[rgba(103,232,249,0.12)]'
|
? '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]'
|
: '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={`h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-[#35d27f]' : 'bg-[#ef4444]'}`} />
|
||||||
<span className="min-w-0 flex-1 overflow-hidden">
|
<span className="min-w-0 flex-1">
|
||||||
<span title={platform.hostName} className="block truncate text-[12px] font-bold text-white/86">{platform.hostName}</span>
|
<span 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 className="mt-0.5 block truncate text-[11px] font-medium text-white/42">{platform.dnsName || platform.loginName || '플랫폼 장비'}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -682,42 +664,15 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
|
|||||||
<p className="text-[13px] font-bold text-white/86">플랫폼 등록</p>
|
<p className="text-[13px] font-bold text-white/86">플랫폼 등록</p>
|
||||||
<p className="mt-1 text-[11px] font-medium leading-5 text-white/48">에이전트 설치 화면에서 현재 계정 이메일과 등록 키를 입력하세요.</p>
|
<p className="mt-1 text-[11px] font-medium leading-5 text-white/48">에이전트 설치 화면에서 현재 계정 이메일과 등록 키를 입력하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void createAgentRegistrationCode()}
|
onClick={() => void createAgentRegistrationCode()}
|
||||||
disabled={registrationLoading}
|
disabled={registrationLoading}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
키 발급
|
키 발급
|
||||||
</button>
|
</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>
|
||||||
</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 && (
|
{registrationCode && (
|
||||||
<div className="mt-3 flex items-center justify-between gap-3">
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
@@ -1145,61 +1100,19 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, onTailscaleRetry, onClose }: {
|
function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, onClose }: {
|
||||||
launcherUpdateAvailable: boolean
|
launcherUpdateAvailable: boolean
|
||||||
launcherUpdating: boolean
|
launcherUpdating: boolean
|
||||||
launcherUpdateMessage: string
|
launcherUpdateMessage: string
|
||||||
onLauncherUpdate: () => void
|
onLauncherUpdate: () => void
|
||||||
onTailscaleRetry?: () => void
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTabKey>('general')
|
const [activeTab, setActiveTab] = useState<SettingsTabKey>('general')
|
||||||
const [clearingCache, setClearingCache] = useState(false)
|
const [clearingCache, setClearingCache] = useState(false)
|
||||||
const [cacheAvailable, setCacheAvailable] = useState(true)
|
const [cacheAvailable, setCacheAvailable] = useState(true)
|
||||||
const [cacheMessage, setCacheMessage] = useState('')
|
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 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> => {
|
const handleClearCache = async (): Promise<void> => {
|
||||||
if (!cacheAvailable) return
|
if (!cacheAvailable) return
|
||||||
|
|
||||||
@@ -1241,7 +1154,6 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda
|
|||||||
<div className="grid h-full grid-cols-[138px_1fr]">
|
<div className="grid h-full grid-cols-[138px_1fr]">
|
||||||
<aside className="border-r border-[rgba(255,255,255,0.08)] p-3">
|
<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={<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={<Palette size={15} />} label="테마" active={activeTab === 'theme'} onClick={() => setActiveTab('theme')} />
|
||||||
<SettingsTab icon={<Info size={15} />} label="정보" active={activeTab === 'info'} onClick={() => setActiveTab('info')} />
|
<SettingsTab icon={<Info size={15} />} label="정보" active={activeTab === 'info'} onClick={() => setActiveTab('info')} />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1307,55 +1219,6 @@ 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' && (
|
{activeTab === 'info' && (
|
||||||
<>
|
<>
|
||||||
<h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">정보</h3>
|
<h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">정보</h3>
|
||||||
|
|||||||
Vendored
-11
@@ -40,10 +40,6 @@ declare global {
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
message: string
|
message: string
|
||||||
}>
|
}>
|
||||||
ensureOperator: () => Promise<{
|
|
||||||
ok: boolean
|
|
||||||
message: string
|
|
||||||
}>
|
|
||||||
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
|
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
message: string
|
message: string
|
||||||
@@ -52,10 +48,6 @@ declare global {
|
|||||||
ok: boolean
|
ok: boolean
|
||||||
message: string
|
message: string
|
||||||
}>
|
}>
|
||||||
restartService: () => Promise<{
|
|
||||||
ok: boolean
|
|
||||||
message: string
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
launcherSshKey?: {
|
launcherSshKey?: {
|
||||||
ensure: () => Promise<{
|
ensure: () => Promise<{
|
||||||
@@ -70,9 +62,6 @@ declare global {
|
|||||||
callback: (payload: { loginToken?: string; error?: string }) => void
|
callback: (payload: { loginToken?: string; error?: string }) => void
|
||||||
) => () => void
|
) => () => void
|
||||||
}
|
}
|
||||||
launcherSession?: {
|
|
||||||
endUserSession: () => Promise<{ ok: boolean }>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,9 @@ interface DashboardProps {
|
|||||||
|
|
||||||
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
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 MANUAL_BASE_URL = (import.meta.env.VITE_MANUAL_BASE_URL || 'https://portal.autonomystudio.net/manual').replace(/\/+$/, '')
|
||||||
const LMS_WEB_BASE_URL = `${(
|
const LMS_WEB_BASE_URL = `${(import.meta.env.VITE_LMS_WEB_BASE_URL || 'http://localhost:5177/lms-web/').replace(/\/+$/, '')}/`
|
||||||
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 NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
|
||||||
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
||||||
const TAILSCALE_DEVICE_ID_STORAGE_KEY = 'atnmy.launcher.tailscale-device-id'
|
|
||||||
const IS_DEV = import.meta.env.DEV
|
const IS_DEV = import.meta.env.DEV
|
||||||
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
|
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
@@ -103,21 +99,6 @@ function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount:
|
|||||||
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTailscaleDeviceId(): string {
|
|
||||||
try {
|
|
||||||
const stored = window.localStorage.getItem(TAILSCALE_DEVICE_ID_STORAGE_KEY)?.trim()
|
|
||||||
if (stored && /^[a-z0-9-]{6,24}$/i.test(stored)) return stored
|
|
||||||
|
|
||||||
const next = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
||||||
? crypto.randomUUID().replace(/-/g, '').slice(0, 10)
|
|
||||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
|
||||||
window.localStorage.setItem(TAILSCALE_DEVICE_ID_STORAGE_KEY, next)
|
|
||||||
return next
|
|
||||||
} catch {
|
|
||||||
return 'launcher-client'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
status?.backendState === 'Unavailable' &&
|
status?.backendState === 'Unavailable' &&
|
||||||
@@ -178,11 +159,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
const noticeRef = useRef<HTMLDivElement>(null)
|
const noticeRef = useRef<HTMLDivElement>(null)
|
||||||
const appActionMenuRef = useRef<HTMLDivElement>(null)
|
const appActionMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const initialAppSelectionDoneRef = useRef(false)
|
const initialAppSelectionDoneRef = useRef(false)
|
||||||
const authSessionRef = useRef(authSession)
|
const tailscaleProvisionUserRef = useRef('')
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
authSessionRef.current = authSession
|
|
||||||
}, [authSession])
|
|
||||||
|
|
||||||
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
|
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
|
||||||
const hasNotices = appNotices.length > 0
|
const hasNotices = appNotices.length > 0
|
||||||
@@ -211,15 +188,10 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
: null
|
: null
|
||||||
const activeAppDisplayName = appStatus?.displayName || appConfig?.displayName || activeApp
|
const activeAppDisplayName = appStatus?.displayName || appConfig?.displayName || activeApp
|
||||||
const refreshSession = useCallback(async (): Promise<AuthSession | null> => {
|
const refreshSession = useCallback(async (): Promise<AuthSession | null> => {
|
||||||
try {
|
|
||||||
const session = await refreshLauncherSession()
|
const session = await refreshLauncherSession()
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
onSessionUpdate(session)
|
onSessionUpdate(session)
|
||||||
return session
|
return session
|
||||||
} catch (error) {
|
|
||||||
console.warn('[launcher] session refresh temporarily failed', error)
|
|
||||||
return authSessionRef.current
|
|
||||||
}
|
|
||||||
}, [onSessionUpdate])
|
}, [onSessionUpdate])
|
||||||
|
|
||||||
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
|
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||||
@@ -244,14 +216,14 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
response = await request(refreshed.accessToken)
|
response = await request(refreshed.accessToken)
|
||||||
return response
|
return response
|
||||||
}, [authSession.accessToken, onSessionExpired, refreshSession])
|
}, [authSession.accessToken, onSessionExpired, refreshSession])
|
||||||
const fetchWithAuthRefreshRef = useRef(fetchWithAuthRefresh)
|
|
||||||
fetchWithAuthRefreshRef.current = fetchWithAuthRefresh
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const launcherTailscale = window.launcherTailscale
|
const launcherTailscale = window.launcherTailscale
|
||||||
if (!launcherTailscale) return
|
if (!launcherTailscale) return
|
||||||
const userEmail = authSession.user.email.trim().toLowerCase()
|
const userEmail = authSession.user.email.trim().toLowerCase()
|
||||||
if (!userEmail) return
|
const provisionKey = `${userEmail}:${tailscaleRetryNonce}`
|
||||||
|
if (!userEmail || tailscaleProvisionUserRef.current === provisionKey) return
|
||||||
|
tailscaleProvisionUserRef.current = provisionKey
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const connectTailscaleInBackground = async (): Promise<void> => {
|
const connectTailscaleInBackground = async (): Promise<void> => {
|
||||||
@@ -273,9 +245,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAccountIdentity = Boolean(
|
|
||||||
status?.loginName?.trim() || status?.userName?.trim(),
|
|
||||||
)
|
|
||||||
const isSameAccount = Boolean(
|
const isSameAccount = Boolean(
|
||||||
status &&
|
status &&
|
||||||
(
|
(
|
||||||
@@ -283,7 +252,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
isSameTailscaleAccount(status.userName, userEmail)
|
isSameTailscaleAccount(status.userName, userEmail)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (status?.connected && (!hasAccountIdentity || isSameAccount)) {
|
if (status?.connected && isSameAccount) {
|
||||||
setTailscaleIssue(null)
|
setTailscaleIssue(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -291,13 +260,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
|
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
const response = await fetchWithAuthRefreshRef.current(`${LAUNCHER_API_BASE_URL}/api/provision/session`, {
|
const response = await fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/provision/session`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
hostname: window.navigator.userAgent.includes('Electron')
|
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
|
||||||
? `launcher-client-${getTailscaleDeviceId()}`
|
|
||||||
: `client-${getTailscaleDeviceId()}`,
|
|
||||||
usageType: 'launcher-client',
|
usageType: 'launcher-client',
|
||||||
publicSshKey: sshKey?.publicKey,
|
publicSshKey: sshKey?.publicKey,
|
||||||
}),
|
}),
|
||||||
@@ -318,17 +285,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldResetSession = Boolean(
|
|
||||||
status &&
|
|
||||||
status.backendState !== 'Unavailable' &&
|
|
||||||
hasAccountIdentity &&
|
|
||||||
!isSameAccount,
|
|
||||||
)
|
|
||||||
const result = await launcherTailscale.login(
|
const result = await launcherTailscale.login(
|
||||||
provision.tailscaleAuthKey,
|
provision.tailscaleAuthKey,
|
||||||
provision.tailscaleLoginServer ?? '',
|
provision.tailscaleLoginServer ?? '',
|
||||||
provision.tailscaleHostname,
|
provision.tailscaleHostname,
|
||||||
shouldResetSession,
|
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
|
||||||
)
|
)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setTailscaleIssue({
|
setTailscaleIssue({
|
||||||
@@ -360,7 +321,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
}
|
}
|
||||||
})(), TAILSCALE_BACKGROUND_TIMEOUT_MS, '원격 연결 확인 시간이 초과되었습니다.')
|
})(), TAILSCALE_BACKGROUND_TIMEOUT_MS, '원격 연결 확인 시간이 초과되었습니다.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (cancelled) return
|
|
||||||
setTailscaleIssue({
|
setTailscaleIssue({
|
||||||
state: 'warning',
|
state: 'warning',
|
||||||
title: '원격 연결 확인 필요',
|
title: '원격 연결 확인 필요',
|
||||||
@@ -375,7 +335,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [authSession.user.email, tailscaleRetryNonce])
|
}, [authSession.user.email, fetchWithAuthRefresh, tailscaleRetryNonce])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -787,11 +747,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
|
|
||||||
let handoffCode: string | undefined
|
let handoffCode: string | undefined
|
||||||
try {
|
try {
|
||||||
await window.electron.ipcRenderer.invoke('prepare-app-runtime', {
|
|
||||||
appName: activeApp,
|
|
||||||
execPath: appConfig.execPath,
|
|
||||||
devAppPath: appConfig.devAppPath,
|
|
||||||
})
|
|
||||||
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
|
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
|
||||||
if (handoffAudienceApp) {
|
if (handoffAudienceApp) {
|
||||||
const [handoffToken] = await Promise.all([
|
const [handoffToken] = await Promise.all([
|
||||||
@@ -817,8 +772,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
})
|
})
|
||||||
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
||||||
setRunningApps((prev) => new Set([...prev, activeApp]))
|
setRunningApps((prev) => new Set([...prev, activeApp]))
|
||||||
} catch (error) {
|
|
||||||
alert(error instanceof Error ? error.message : '앱 실행 준비에 실패했습니다.')
|
|
||||||
} finally {
|
} finally {
|
||||||
setLaunchingApp(null)
|
setLaunchingApp(null)
|
||||||
}
|
}
|
||||||
@@ -1005,9 +958,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
) : canLaunch ? (
|
) : canLaunch ? (
|
||||||
activeAppLaunching ? (
|
activeAppLaunching ? (
|
||||||
<button className="launch-pending-btn" disabled>
|
<button className="launch-pending-btn" disabled>
|
||||||
<svg aria-hidden="true" className="spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
|
|
||||||
<path d="M12 2a10 10 0 1 0 10 10" />
|
|
||||||
</svg>
|
|
||||||
실행중
|
실행중
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -182,9 +182,6 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
|
|||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 429) {
|
|
||||||
throw new Error('로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해주세요.')
|
|
||||||
}
|
|
||||||
throw new Error('이메일 또는 비밀번호를 확인하세요.')
|
throw new Error('이메일 또는 비밀번호를 확인하세요.')
|
||||||
}
|
}
|
||||||
const session = await response.json() as AuthSession
|
const session = await response.json() as AuthSession
|
||||||
|
|||||||
+1
-3
@@ -2,9 +2,7 @@
|
|||||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": false,
|
"composite": true,
|
||||||
"incremental": false,
|
|
||||||
"noEmit": true,
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"types": ["electron-vite/node"]
|
"types": ["electron-vite/node"]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+1
-3
@@ -2,9 +2,7 @@
|
|||||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
"include": ["src/renderer/src/**/*"],
|
"include": ["src/renderer/src/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": false,
|
"composite": true,
|
||||||
"incremental": false,
|
|
||||||
"noEmit": true,
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
Reference in New Issue
Block a user