init studio launcher frontend

This commit is contained in:
2026-07-02 11:20:40 +09:00
commit a294b724b4
93 changed files with 17158 additions and 0 deletions
+950
View File
@@ -0,0 +1,950 @@
import { app, shell, BrowserWindow, ipcMain, protocol, net, session as electronSession, dialog } from 'electron'
import { join, dirname, resolve } from 'path'
import { chmodSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
import { get as httpGet } from 'http'
import { get as httpsGet } from 'https'
import { spawn, exec, ChildProcess } from 'child_process'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
protocol.registerSchemesAsPrivileged([
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
])
let splash: BrowserWindow | null = null
type CommandResult = {
ok: boolean
message: string
}
type TailscaleStatusResult = {
connected: boolean
ip: string
backendState: string
online?: boolean
health?: string[]
loginName?: string
userName?: string
peers?: TailscalePeerStatus[]
error?: string
}
type TailscalePeerStatus = {
id: string
hostName: string
dnsName?: string
os?: string
userName?: string
loginName?: string
ip: string
ips: string[]
online: boolean
active: boolean
lastSeen?: string
relay?: string
currentAddress?: string
}
type SshKeyResult = {
publicKey: string
publicKeyPath: string
privateKeyPath: string
}
function getLauncherDir(): string {
if (process.env.APPIMAGE) return dirname(process.env.APPIMAGE)
if (is.dev) return process.cwd()
return dirname(process.execPath)
}
function resolveExecPath(execPath?: string): string | undefined {
if (!execPath) return undefined
return execPath.startsWith('./')
? join(getLauncherDir(), execPath.slice(2))
: execPath
}
function getWorkspaceRoot(): string {
return is.dev ? resolve(process.cwd(), '..') : getLauncherDir()
}
function getLauncherSshKeyPath(): string {
return join(app.getPath('home'), '.autonomy-studio', 'ssh', 'id_ed25519')
}
function resolveDevAppPath(devAppPath?: string): string | undefined {
if (!devAppPath) return undefined
return devAppPath.startsWith('./') || devAppPath.startsWith('../')
? resolve(getWorkspaceRoot(), devAppPath)
: devAppPath
}
function getDefaultDevAppPath(appName: string): string | undefined {
const devPathByApp: Record<string, string> = {
'ADS IDE': './apps/ads-ide/ads-ide-fe',
LMS: './apps/lms/lms-fe',
}
return resolveDevAppPath(devPathByApp[appName])
}
function isRunnableDevAppPath(devAppPath?: string): boolean {
return Boolean(devAppPath && existsSync(join(devAppPath, 'package.json')))
}
function writeDevHandoffFile(appName: string, appUrl: string, handoffToken: string, audienceApp: string): string {
const safeAppName = appName.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase()
const filePath = join(app.getPath('temp'), `autonomy-studio-${safeAppName}-handoff-${process.pid}.json`)
writeFileSync(filePath, JSON.stringify({
appUrl,
handoffToken,
audienceApp,
createdAt: new Date().toISOString()
}))
return filePath
}
function getVersionFilePath(targetPath: string): string {
return `${targetPath}.version`
}
function readInstalledVersion(targetPath?: string): string | null {
if (!targetPath) return null
const versionFilePath = getVersionFilePath(targetPath)
if (!existsSync(versionFilePath)) return null
try {
return readFileSync(versionFilePath, 'utf8').trim() || null
} catch {
return null
}
}
function removeMatchingFiles(dirPath: string, shouldRemove: (fileName: string) => boolean): number {
if (!existsSync(dirPath)) return 0
let removed = 0
for (const entry of readdirSync(dirPath)) {
const fullPath = join(dirPath, entry)
try {
if (statSync(fullPath).isFile() && shouldRemove(entry)) {
rmSync(fullPath, { force: true })
removed += 1
}
} catch {
// Ignore files that disappear while cleanup is running.
}
}
return removed
}
function countMatchingFiles(dirPath: string, shouldCount: (fileName: string) => boolean): number {
if (!existsSync(dirPath)) return 0
let count = 0
for (const entry of readdirSync(dirPath)) {
const fullPath = join(dirPath, entry)
try {
if (statSync(fullPath).isFile() && shouldCount(entry)) {
count += 1
}
} catch {
// Ignore files that disappear while cache state is being checked.
}
}
return count
}
async function getLauncherCacheStatus(): Promise<{ available: boolean; files: number; rendererCacheBytes: number }> {
const tempFiles = countMatchingFiles(app.getPath('temp'), (fileName) =>
/^autonomy-studio-launcher-.+\.deb(?:\.download)?$/.test(fileName)
)
const downloadFiles = countMatchingFiles(getLauncherDir(), (fileName) => fileName.endsWith('.download'))
const rendererCacheBytes = await electronSession.defaultSession.getCacheSize()
return {
available: tempFiles + downloadFiles > 0 || rendererCacheBytes > 0,
files: tempFiles + downloadFiles,
rendererCacheBytes,
}
}
async function clearLauncherCache(): Promise<{ ok: true; removedFiles: number }> {
const removedTempFiles = removeMatchingFiles(app.getPath('temp'), (fileName) =>
/^autonomy-studio-launcher-.+\.deb(?:\.download)?$/.test(fileName)
)
const removedDownloadFiles = removeMatchingFiles(getLauncherDir(), (fileName) => fileName.endsWith('.download'))
await electronSession.defaultSession.clearCache()
return {
ok: true,
removedFiles: removedTempFiles + removedDownloadFiles,
}
}
function downloadFile(
url: string,
targetPath: string,
onProgress: (progress: number) => void,
): Promise<void> {
const tmpPath = `${targetPath}.download`
const client = url.startsWith('https:') ? httpsGet : httpGet
return new Promise((resolve, reject) => {
mkdirSync(dirname(targetPath), { recursive: true })
rmSync(tmpPath, { force: true })
const request = client(url, (response) => {
const statusCode = response.statusCode ?? 0
const redirectUrl = response.headers.location
if (statusCode >= 300 && statusCode < 400 && redirectUrl) {
response.resume()
const nextUrl = new URL(redirectUrl, url).toString()
downloadFile(nextUrl, targetPath, onProgress).then(resolve).catch(reject)
return
}
if (statusCode < 200 || statusCode >= 300) {
response.resume()
reject(new Error(`Download failed with status ${statusCode}`))
return
}
const total = Number(response.headers['content-length'] ?? 0)
let received = 0
const file = createWriteStream(tmpPath)
response.on('data', (chunk: Buffer) => {
received += chunk.length
if (total > 0) {
onProgress(Math.min(99, Math.round((received / total) * 100)))
}
})
response.pipe(file)
file.on('finish', () => {
file.close(() => {
renameSync(tmpPath, targetPath)
chmodSync(targetPath, 0o755)
onProgress(100)
resolve()
})
})
file.on('error', (error) => {
rmSync(tmpPath, { force: true })
reject(error)
})
})
request.on('error', (error) => {
rmSync(tmpPath, { force: true })
reject(error)
})
})
}
function installDebPackage(debPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('pkexec', ['dpkg', '-i', debPath], {
stdio: 'ignore'
})
child.on('error', (error) => {
reject(error)
})
child.on('close', (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`Launcher update failed with exit code ${code ?? 'unknown'}`))
}
})
})
}
function runCommand(command: string, args: string[], timeoutMs = 30000, extraEnv?: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGTERM')
reject(new Error(`${command} timed out`))
}, timeoutMs)
child.stdout.on('data', (chunk) => {
stdout += String(chunk)
})
child.stderr.on('data', (chunk) => {
stderr += String(chunk)
})
child.on('error', (error) => {
clearTimeout(timer)
reject(error)
})
child.on('close', (code) => {
clearTimeout(timer)
if (code === 0) {
resolve({ stdout, stderr })
} else {
reject(new Error(stderr.trim() || stdout.trim() || `${command} exited with code ${code ?? 'unknown'}`))
}
})
})
}
function commandErrorMessage(error: unknown, fallback: string): string {
const message = error instanceof Error ? error.message : String(error)
if (/request dismissed|dismissed|cancelled|canceled/i.test(message)) return '인증이 취소되었습니다'
if (/not authorized|authentication failed/i.test(message)) return '관리자 인증에 실패했습니다'
if (/No such file or directory|ENOENT/i.test(message)) return '명령을 찾을 수 없습니다'
const sanitized = message
.replace(/(?:tskey|hskey)-auth-[A-Za-z0-9_-]+/g, '[auth-key]')
.trim()
return sanitized ? `${fallback}: ${sanitized}` : fallback
}
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
function isTailscalePermissionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /set --operator|not require root|permission denied|access denied|must be root|requires root/i.test(message)
}
function tailscaleCommandEnv(loginServer: string): NodeJS.ProcessEnv | undefined {
return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined
}
function hasTailscaleCoordinationIssue(health?: string[]): boolean {
return Boolean(
health?.some((message) =>
/coordination server|synchronize the state|control server|login server|headscale/i.test(message),
),
)
}
async function runCommandIfPossible(
command: string,
args: string[],
timeoutMs = 10000,
extraEnv?: NodeJS.ProcessEnv,
): Promise<boolean> {
try {
await runCommand(command, args, timeoutMs, extraEnv)
return true
} catch {
return false
}
}
async function ensureLauncherSshKey(): Promise<SshKeyResult> {
const privateKeyPath = getLauncherSshKeyPath()
const publicKeyPath = `${privateKeyPath}.pub`
mkdirSync(dirname(privateKeyPath), { recursive: true, mode: 0o700 })
if (!existsSync(privateKeyPath) || !existsSync(publicKeyPath)) {
rmSync(privateKeyPath, { force: true })
rmSync(publicKeyPath, { force: true })
await runCommand('ssh-keygen', [
'-t',
'ed25519',
'-N',
'',
'-C',
'autonomy-studio-launcher',
'-f',
privateKeyPath,
])
}
chmodSync(dirname(privateKeyPath), 0o700)
chmodSync(privateKeyPath, 0o600)
chmodSync(publicKeyPath, 0o644)
return {
publicKey: readFileSync(publicKeyPath, 'utf8').trim(),
publicKeyPath,
privateKeyPath,
}
}
async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
await runCommandIfPossible('tailscale', ['logout'], 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> {
const attempts: Array<[string, string[]]> = [
['tailscale', ['status', '--json']],
['tailscale', ['status']],
]
let lastError = ''
for (const [command, args] of attempts) {
try {
const { stdout } = await runCommand(command, args)
if (args.includes('--json')) {
const status = JSON.parse(stdout) as {
BackendState?: string
Self?: { TailscaleIPs?: string[]; UserID?: number; LoginName?: string; Online?: boolean }
Peer?: Record<string, {
ID?: string
HostName?: string
DNSName?: string
OS?: string
UserID?: number
TailscaleIPs?: string[]
Online?: boolean
Active?: boolean
LastSeen?: string
Relay?: string
CurAddr?: string
}>
Health?: string[]
User?: Record<string, { LoginName?: string; ProfilePicURL?: string; DisplayName?: string }>
}
const selfUser = status.Self?.UserID != null ? status.User?.[String(status.Self.UserID)] : undefined
const health = Array.isArray(status.Health) ? status.Health : []
const online = status.Self?.Online === true
const connected = status.BackendState === 'Running' && online && !hasTailscaleCoordinationIssue(health)
const peers = Object.entries(status.Peer ?? {}).map(([key, peer]) => {
const user = peer.UserID != null ? status.User?.[String(peer.UserID)] : undefined
const ips = Array.isArray(peer.TailscaleIPs) ? peer.TailscaleIPs : []
return {
id: peer.ID ?? key,
hostName: peer.HostName ?? peer.DNSName ?? key,
dnsName: peer.DNSName,
os: peer.OS,
userName: user?.DisplayName,
loginName: user?.LoginName,
ip: ips[0] ?? '',
ips,
online: peer.Online === true,
active: peer.Active === true,
lastSeen: peer.LastSeen,
relay: peer.Relay,
currentAddress: peer.CurAddr,
}
})
return {
connected,
ip: status.Self?.TailscaleIPs?.[0] ?? '',
backendState: status.BackendState ?? 'Unknown',
online,
health,
loginName: status.Self?.LoginName ?? selfUser?.LoginName ?? '',
userName: selfUser?.DisplayName ?? '',
peers,
error: connected ? undefined : health[0],
}
}
const output = stdout.trim()
const connected = Boolean(output) && !/logged out|not logged in/i.test(output)
return {
connected,
ip: output.match(/\b100\.\d+\.\d+\.\d+\b/)?.[0] ?? '',
backendState: connected ? 'Running' : 'Stopped',
}
} catch (error) {
lastError = error instanceof Error ? error.message : String(error)
}
}
return {
connected: false,
ip: '',
backendState: 'Unavailable',
error: lastError || 'tailscale status failed',
}
}
async function loginTailscale(
authKey: string,
loginServer: string,
hostname?: string,
forceReset = false,
): Promise<CommandResult> {
const tailscaleArgs = ['up']
const trimmedAuthKey = authKey.trim()
const trimmedLoginServer = loginServer.trim()
const trimmedHostname = hostname?.trim() ?? ''
const commandEnv = tailscaleCommandEnv(trimmedLoginServer)
if (forceReset) {
await resetTailscaleSession(commandEnv)
tailscaleArgs.push('--reset')
}
if (trimmedLoginServer) {
tailscaleArgs.push('--login-server', trimmedLoginServer)
}
if (trimmedAuthKey) {
tailscaleArgs.push('--auth-key', trimmedAuthKey)
}
if (trimmedHostname) {
tailscaleArgs.push('--hostname', trimmedHostname)
}
try {
await runCommand('tailscale', tailscaleArgs, 30000, commandEnv)
await waitForTailscaleConnected()
return { ok: true, message: 'Tailscale 연결 완료' }
} catch (error) {
if (isTailscalePermissionError(error)) {
try {
if (forceReset) {
await resetTailscaleSessionWithPkexec(commandEnv)
}
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
await waitForTailscaleConnected()
return { ok: true, message: 'Tailscale 연결 완료' }
} catch (pkexecError) {
return {
ok: false,
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다.')),
}
}
}
return {
ok: false,
message: commandErrorMessage(error, 'Tailscale 연결 실패'),
}
}
}
async function waitForTailscaleConnected(): Promise<void> {
let lastStatus: TailscaleStatusResult | null = null
for (let i = 0; i < 30; i += 1) {
await delay(500)
const status = await readTailscaleStatus()
lastStatus = status
if (status.connected) return
}
throw new Error(lastStatus?.error || `Tailscale state is ${lastStatus?.backendState ?? 'not connected'}`)
}
async function logoutTailscale(): Promise<CommandResult> {
try {
await runCommand('tailscale', ['logout'])
return { ok: true, message: 'Tailscale 로그아웃 완료' }
} catch (error) {
if (isTailscalePermissionError(error)) {
try {
await runCommand('tailscale', ['down'])
return { ok: true, message: 'Tailscale 연결 해제 완료' }
} catch (downError) {
return {
ok: false,
message: commandErrorMessage(downError, commandErrorMessage(error, 'Tailscale 로그아웃 권한이 없습니다.')),
}
}
}
return {
ok: false,
message: commandErrorMessage(error, 'Tailscale 로그아웃 실패'),
}
}
}
function createSplashWindow(): void {
splash = new BrowserWindow({
width: 580,
height: 360,
show: true,
frame: false,
resizable: false,
center: true,
autoHideMenuBar: true,
backgroundColor: '#07111f'
})
const splashPath = is.dev
? join(process.cwd(), 'resources/splash.html')
: join(__dirname, '../renderer/splash.html')
splash.loadFile(splashPath, { query: { version: app.getVersion() } })
}
function attachDevToolsShortcut(window: BrowserWindow): void {
window.webContents.on('before-input-event', (event, input) => {
const key = input.key.toLowerCase()
if (input.key === 'F12' || (input.control && input.shift && key === 'i')) {
event.preventDefault()
window.webContents.toggleDevTools()
}
})
}
function createWindow(): void {
const appWindows = new Map<string, BrowserWindow>()
const appProcesses = new Map<string, ChildProcess>()
const mainWindow = new BrowserWindow({
width: 1366,
height: 860,
icon: join(process.cwd(), 'resources/icon.png'),
show: false,
frame: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
attachDevToolsShortcut(mainWindow)
mainWindow.on('ready-to-show', () => {
setTimeout(() => {
splash?.close()
splash = null
mainWindow.show()
}, 1500)
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
ipcMain.on('window-minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.on('window-maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
win.isMaximized() ? win.unmaximize() : win.maximize()
})
ipcMain.on('window-close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close()
})
ipcMain.handle('get-app-installation', (_, payload: {
appName?: string
execPath?: string
devAppPath?: string
}) => {
const devAppPath = resolveDevAppPath(payload.devAppPath) ?? (payload.appName ? getDefaultDevAppPath(payload.appName) : undefined)
if (is.dev && devAppPath) {
const packagePath = join(devAppPath, 'package.json')
let version: string | null = null
try {
version = JSON.parse(readFileSync(packagePath, 'utf8')).version ?? null
} catch {
version = null
}
return {
installed: existsSync(packagePath),
version,
path: devAppPath,
}
}
const targetPath = resolveExecPath(payload.execPath)
return {
installed: targetPath ? existsSync(targetPath) : false,
version: readInstalledVersion(targetPath),
path: targetPath ?? null,
}
})
ipcMain.handle('download-app', async (event, payload: {
appName: string
downloadUrl: string
execPath?: string
latestVersion?: string
}) => {
const targetPath = resolveExecPath(payload.execPath)
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
event.sender.send('app-download-progress', {
appName: payload.appName,
progress,
})
})
if (payload.latestVersion) {
writeFileSync(getVersionFilePath(targetPath), `${payload.latestVersion}\n`, 'utf8')
}
return {
installed: true,
version: payload.latestVersion ?? readInstalledVersion(targetPath),
path: targetPath,
}
})
ipcMain.handle('install-launcher-update', async (event, payload: {
downloadUrl: string
latestVersion?: string
}) => {
const version = payload.latestVersion?.replace(/[^0-9A-Za-z._-]/g, '') || String(Date.now())
const debPath = join(app.getPath('temp'), `autonomy-studio-launcher-${version}.deb`)
await downloadFile(payload.downloadUrl, debPath, (progress) => {
event.sender.send('app-download-progress', {
appName: 'launcher',
progress,
})
})
await installDebPackage(debPath)
if (!is.dev) {
app.relaunch()
app.quit()
}
return {
installed: true,
version: payload.latestVersion ?? null,
path: debPath,
}
})
ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus())
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
ipcMain.handle(
'launcher-tailscale-login',
(_event, authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)),
)
ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale())
const onAppClosed = (appName: string): void => {
mainWindow.webContents.send('app-window-closed', appName)
if (appWindows.size === 0 && appProcesses.size === 0) {
mainWindow.restore()
mainWindow.focus()
}
}
ipcMain.on('launch-app', (_, payload: {
appName: string
url: string
width?: number
height?: number
devAppPath?: string
execPath?: string
handoffCode?: string
audienceApp?: string
}) => {
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 authLaunchArgs = handoffCode
? [
`--handoff-token=${encodeURIComponent(handoffCode)}`,
`--audience-app=${encodeURIComponent(audienceApp)}`
]
: []
// 이미 실행 중이면 포커스
const existingWin = appWindows.get(appName)
if (existingWin && !existingWin.isDestroyed()) {
if (authProtocolUrl) shell.openExternal(authProtocolUrl)
existingWin.focus()
return
}
const existingProc = appProcesses.get(appName)
if (existingProc) {
if (is.dev && handoffCode && audienceApp !== 'ADS IDE') {
existingProc.kill()
appProcesses.delete(appName)
} else {
if (authProtocolUrl) shell.openExternal(authProtocolUrl)
return
}
}
mainWindow.minimize()
// ── 별도 Electron 프로세스로 실행 ──
const resolvedExecPath = resolveExecPath(execPath)
const resolvedDevAppPath = resolveDevAppPath(devAppPath) ?? getDefaultDevAppPath(appName)
if (is.dev && resolvedDevAppPath && !isRunnableDevAppPath(resolvedDevAppPath)) {
dialog.showErrorBox(
'개발 앱 실행 실패',
`${appName} 개발 앱 경로를 찾을 수 없습니다.\n${resolvedDevAppPath}`
)
return
}
const spawnPath = is.dev && resolvedDevAppPath && isRunnableDevAppPath(resolvedDevAppPath)
? resolvedDevAppPath
: resolvedExecPath && existsSync(resolvedExecPath)
? resolvedExecPath
: resolvedExecPath
if (spawnPath) {
const useDevLauncher = is.dev && spawnPath === resolvedDevAppPath
const args = useDevLauncher
? ['run', 'dev']
: [`--url=${encodeURIComponent(url)}`, ...authLaunchArgs, ...(authProtocolUrl ? [authProtocolUrl] : [])]
const execBin = useDevLauncher ? 'npm' : spawnPath
const childEnv = { ...process.env }
if (useDevLauncher) {
childEnv.ATNMY_APP_URL = url
}
if (useDevLauncher && handoffCode) {
childEnv.ATNMY_HANDOFF_FILE = writeDevHandoffFile(appName, url, handoffCode, audienceApp)
childEnv.ATNMY_HANDOFF_TOKEN = handoffCode
childEnv.ATNMY_AUDIENCE_APP = audienceApp
} else if (handoffCode) {
childEnv.ATNMY_HANDOFF_TOKEN = handoffCode
childEnv.ATNMY_AUDIENCE_APP = audienceApp
}
delete childEnv.ELECTRON_RENDERER_URL
delete childEnv.VITE_DEV_SERVER_URL
const logPath = useDevLauncher ? join(app.getPath('temp'), `autonomy-studio-${appName.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase()}.dev.log`) : undefined
const logFd = logPath ? openSync(logPath, 'a') : undefined
if (logPath) {
writeFileSync(logPath, [
`\n[${new Date().toISOString()}] ${execBin} ${args.join(' ')}`,
`[launcher] app=${appName} useDevLauncher=${useDevLauncher ? 'true' : 'false'} hasHandoff=${handoffCode ? 'true' : 'false'} audience=${audienceApp}`,
`[launcher] handoffFile=${childEnv.ATNMY_HANDOFF_FILE ?? ''} hasHandoffTokenEnv=${childEnv.ATNMY_HANDOFF_TOKEN ? 'true' : 'false'}`,
''
].join('\n'), { flag: 'a' })
}
const child = spawn(execBin, args, {
detached: true,
stdio: useDevLauncher && logFd !== undefined ? ['ignore', logFd, logFd] : 'ignore',
cwd: useDevLauncher ? spawnPath : undefined,
env: childEnv,
shell: useDevLauncher && process.platform === 'win32'
})
child.unref()
appProcesses.set(appName, child)
child.on('error', (error) => {
appProcesses.delete(appName)
dialog.showErrorBox(
`${appName} 실행 실패`,
`${error instanceof Error ? error.message : '앱 실행에 실패했습니다.'}${logPath ? `\n\n로그: ${logPath}` : ''}`
)
onAppClosed(appName)
})
child.on('close', () => {
appProcesses.delete(appName)
onAppClosed(appName)
})
return
}
// ── fallback: 같은 프로세스 내 BrowserWindow ──
const appWin = new BrowserWindow({
width,
height,
frame: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
additionalArguments: [
'--app-window',
`--app-name=${encodeURIComponent(appName)}`,
`--app-url=${encodeURIComponent(url)}`
]
}
})
appWindows.set(appName, appWin)
attachDevToolsShortcut(appWin)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
appWin.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
appWin.loadFile(join(__dirname, '../renderer/index.html'))
}
appWin.on('closed', () => {
appWindows.delete(appName)
onAppClosed(appName)
})
})
ipcMain.on('focus-app-window', (_, appName: string) => {
const win = appWindows.get(appName)
if (win && !win.isDestroyed()) { win.focus(); return }
const child = appProcesses.get(appName)
if (!child || child.pid === undefined) return
const pid = child.pid
if (process.platform === 'linux') {
exec(`xdotool search --name "${appName}" windowactivate --sync`)
} else if (process.platform === 'darwin') {
exec(`osascript -e 'tell application "System Events" to set frontmost of (first process whose unix id is ${pid}) to true'`)
} else if (process.platform === 'win32') {
exec(`powershell -command "(New-Object -ComObject wscript.shell).AppActivate(${pid})"`)
}
})
ipcMain.on('close-app-window', (_, appName: string) => {
const win = appWindows.get(appName)
if (win && !win.isDestroyed()) { win.close(); return }
const child = appProcesses.get(appName)
if (child) child.kill()
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.electron')
protocol.handle('appdocs', (request) => {
const url = new URL(request.url)
const docsBase = is.dev
? join(process.cwd(), 'resources/docs')
: join(__dirname, '../renderer/docs')
const filePath = join(docsBase, url.pathname)
return net.fetch(`file://${filePath}`)
})
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
createSplashWindow()
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
+42
View File
@@ -0,0 +1,42 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
const argv = process.argv
const isAppWindow = argv.includes('--app-window')
const appName = decodeURIComponent(
argv.find((a) => a.startsWith('--app-name='))?.slice('--app-name='.length) ?? ''
)
const appUrl = decodeURIComponent(
argv.find((a) => a.startsWith('--app-url='))?.slice('--app-url='.length) ?? ''
)
const appWindowMeta = { isAppWindow, appName, url: appUrl }
const launcherTailscale = {
status: () => ipcRenderer.invoke('launcher-tailscale-status'),
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
}
const launcherSshKey = {
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('appWindowMeta', appWindowMeta)
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore
window.electron = electronAPI
// @ts-ignore
window.appWindowMeta = appWindowMeta
// @ts-ignore
window.launcherTailscale = launcherTailscale
// @ts-ignore
window.launcherSshKey = launcherSshKey
}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Autonomy Studio</title>
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/gh/moonspam/NanumSquare@1.0/nanumsquare.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from 'react'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import AppWindowShell from './pages/AppWindowShell'
import type { AuthSession } from './pages/LoginForm'
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(/\/+$/, '')
function App(): React.JSX.Element {
const [authSession, setAuthSession] = useState<AuthSession | null>(() => {
try {
const raw = window.localStorage.getItem(AUTH_SESSION_KEY) ?? window.sessionStorage.getItem(AUTH_SESSION_KEY)
return raw ? JSON.parse(raw) as AuthSession : null
} catch {
return null
}
})
const meta = window.appWindowMeta
if (meta?.isAppWindow) {
return <AppWindowShell appName={meta.appName} url={meta.url} />
}
const handleSessionUpdate = useCallback((session: AuthSession): void => {
setAuthSession(session)
if (window.localStorage.getItem(AUTH_SESSION_KEY)) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
} else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) {
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
}
}, [])
const handleLogout = useCallback((): void => {
window.launcherTailscale?.logout().catch(() => undefined)
window.localStorage.removeItem(AUTH_SESSION_KEY)
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
setAuthSession(null)
}, [])
useEffect(() => {
if (!authSession?.accessToken) return
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
const timer = window.setTimeout(() => {
fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
.then((response) => response.ok ? response.json() as Promise<AuthSession> : null)
.then((session) => {
if (session?.accessToken) {
handleSessionUpdate(session)
} else {
handleLogout()
}
})
.catch(() => {
handleLogout()
})
}, refreshDelayMs)
return () => window.clearTimeout(timer)
}, [authSession, handleLogout, handleSessionUpdate])
return authSession
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} />
: <Login onLogin={setAuthSession} />
}
export default App
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+933
View File
@@ -0,0 +1,933 @@
@import "tailwindcss";
/* @font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicLight.woff2') format('woff2');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothic.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicBold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'NanumGothic';
src: url('./fonts/NanumGothicExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
} */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.28);
}
:root {
--bg-deep: #0a0b0e;
--bg-sidebar: #0d0f14;
--bg-main: #0f1117;
--border: rgba(255, 255, 255, 0.06);
--accent: #3b7ffa;
--accent-glow: rgba(59, 127, 250, 0.35);
--text-primary: #e8eaf0;
--text-secondary: rgba(220, 225, 238, 0.76);
--text-muted: rgba(157, 163, 175, 0.911);
--text-soft: #d6dae3;
--sidebar-width: 280px;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background: #000;
font-family: 'NanumSquare', sans-serif;
}
/* ── Sidebar ── */
.sidebar {
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 8px 0;
flex-shrink: 0;
position: relative;
z-index: 20;
overflow: visible;
}
.sidebar::after {
content: '';
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 1px;
background: linear-gradient(
to bottom,
transparent,
rgba(59, 127, 250, 0.15) 40%,
rgba(59, 127, 250, 0.15) 60%,
transparent
);
pointer-events: none;
}
.nav-item {
display: flex;
align-items: center;
padding: 13px 24px;
font-size: 13.5px;
font-weight: 400;
color: var(--text-secondary);
cursor: pointer;
position: relative;
letter-spacing: 0.01em;
transition: color 0.2s, background 0.2s;
}
.nav-item:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.03);
}
.nav-item-unavailable {
color: rgba(200, 205, 220, 0.42);
cursor: default;
}
.nav-item-unavailable .nav-item-icon {
opacity: 0.38;
}
.nav-item-unavailable:hover {
color: rgba(200, 205, 220, 0.5);
background: rgba(255, 255, 255, 0.015);
}
.nav-item-unavailable:hover .nav-item-icon {
opacity: 0.46;
}
.nav-item.active {
color: var(--accent);
font-weight: 500;
background: rgba(59, 127, 250, 0.07);
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
background: var(--accent);
border-radius: 0 3px 3px 0;
box-shadow: 0 0 12px var(--accent-glow);
}
.nav-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
margin-right: 10px;
opacity: 0.68;
flex-shrink: 0;
transition: opacity 0.2s;
}
.nav-item:hover .nav-item-icon {
opacity: 0.8;
}
.nav-item.active .nav-item-icon {
opacity: 1;
}
.nav-item-unavailable.active {
color: rgba(200, 205, 220, 0.54);
background: rgba(255, 255, 255, 0.025);
}
.nav-item-unavailable.active::before {
background: rgba(200, 205, 220, 0.24);
box-shadow: none;
}
.nav-item-unavailable.active .nav-item-icon {
opacity: 0.5;
}
.nav-item-collapsed {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
/* ── Sidebar Collapse Toggle Button ── */
.sidebar-collapse-btn {
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
z-index: 50;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-sidebar);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-muted);
transition: color 0.2s, background 0.2s, border-color 0.2s;
}
.sidebar-collapse-btn:hover {
color: var(--text-primary);
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.2);
}
/* ── Main Content ── */
.main-content {
flex: 1;
background: var(--bg-main);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main-content::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 65% 50%, rgba(20, 25, 40, 0.9) 0%, transparent 70%),
radial-gradient(ellipse 50% 50% at 20% 50%, rgba(15, 30, 60, 0.3) 0%, transparent 60%);
pointer-events: none;
}
/* ── Update Button ── */
.update-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
}
.update-btn:hover {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
transform: translateY(-1px);
}
.update-btn:active {
transform: translateY(0);
}
.update-btn:disabled {
background: rgba(59, 127, 250, 0.14);
border-color: rgba(59, 127, 250, 0.24);
color: rgba(255, 255, 255, 0.38);
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.update-btn:disabled:hover {
background: rgba(59, 127, 250, 0.14);
box-shadow: none;
transform: none;
}
/* ── Updating Button (in-progress state) ── */
.updating-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: rgba(59, 127, 250, 0.15);
border: 1px solid rgba(59, 127, 250, 0.3);
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: default;
}
/* ── Update Progress Bar ── */
.update-progress-wrap {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.update-progress-track {
flex: 1;
height: 3px;
background: rgba(255,255,255,0.08);
border-radius: 99px;
overflow: hidden;
}
.update-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.2s ease;
box-shadow: 0 0 6px rgba(59,127,250,0.6);
}
.update-progress-pct {
font-size: 11px;
color: var(--text-muted);
min-width: 30px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Spinner ── */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spin {
animation: spin 0.8s linear infinite;
}
/* ── Launch Button ── */
.launch-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 16px;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
}
.launch-btn:hover {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
transform: translateY(-1px);
}
.launch-btn:active {
transform: translateY(0);
}
.launch-btn:disabled {
background: rgba(59, 127, 250, 0.14);
border-color: rgba(59, 127, 250, 0.24);
color: rgba(255, 255, 255, 0.38);
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.launch-btn:disabled:hover {
background: rgba(59, 127, 250, 0.14);
box-shadow: none;
transform: none;
}
.launch-pending-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 7px 16px;
min-width: 78px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: rgba(255, 255, 255, 0.42);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: not-allowed;
box-shadow: none;
}
/* ── Car Video ── */
.car-video {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center bottom;
opacity: 0;
animation: carFadeIn 1.4s 0.5s ease forwards;
z-index: 2;
}
.car-video-shadow {
position: absolute;
inset: 0;
background:
linear-gradient(to right, var(--bg-main) 0%, var(--bg-main) 14%, rgba(15, 17, 23, 0.45) 32%, transparent 52%),
linear-gradient(to left, var(--bg-main) 0%, rgba(15, 17, 23, 0.6) 8%, transparent 35%),
linear-gradient(to top, rgba(5, 7, 12, 0.78) 0%, transparent 42%),
radial-gradient(ellipse 60% 60% at 100% 100%, rgba(5, 7, 12, 0.56) 0%, transparent 14%),
radial-gradient(ellipse 100% 100% at 80% 40%, transparent 40%, rgba(5, 7, 12, 0.5) 100%);
pointer-events: none;
z-index: 3;
}
.floor-reflection {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 120px;
background: linear-gradient(to top, rgba(8, 10, 18, 0.8) 0%, transparent 100%);
pointer-events: none;
z-index: 1;
}
/* ── Text Content ── */
.text-content {
position: relative;
z-index: 10;
max-width: 480px;
animation: textReveal 1s 0.2s ease both;
}
.brand-title {
font-family: 'Rajdhani', sans-serif;
font-size: 52px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.02em;
line-height: 1.0;
margin-bottom: 20px;
text-shadow: 0 0 60px rgba(59, 127, 250, 0.1);
}
.brand-title span {
color: var(--accent);
}
.subtitle {
font-size: 17px;
font-weight: 400;
color: var(--text-primary);
line-height: 1.7;
margin-bottom: 28px;
letter-spacing: 0.01em;
}
.divider {
width: 140px;
height: 2px;
background: linear-gradient(to right, var(--accent), transparent);
margin-bottom: 22px;
border-radius: 2px;
box-shadow: 0 0 10px var(--accent-glow);
}
.description {
font-size: 13.5px;
color: rgba(200, 210, 230, 0.85);
line-height: 2.0;
letter-spacing: 0.005em;
}
/* ── Verify Code Button ── */
.verify-code-btn {
flex-shrink: 0;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: 1px solid rgba(59, 127, 250, 0.35);
background: rgba(59, 127, 250, 0.10);
color: var(--accent);
white-space: nowrap;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.15s;
font-family: 'NanumGothic', sans-serif;
}
.verify-code-btn:hover:not(:disabled) {
background: rgba(59, 127, 250, 0.2);
border-color: rgba(59, 127, 250, 0.6);
box-shadow: 0 0 12px rgba(59, 127, 250, 0.25);
}
.verify-code-btn:disabled {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.07);
color: var(--text-muted);
cursor: not-allowed;
}
/* ── Notice Icon Button ── */
.notice-icon-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.notice-icon-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
}
.notice-badge {
position: absolute;
top: 7px;
right: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
}
.notice-dropdown {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 320px;
background: rgba(13, 15, 22, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.notice-item {
padding: 12px 16px;
}
.notice-item-border {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
/* ── Notice Panel ── */
.notice-panel {
padding: 14px 16px;
background: rgba(13, 15, 20, 0.75);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.notice-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
flex-shrink: 0;
}
.notice-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.notice-date {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.notice-title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 5px;
line-height: 1.4;
}
.notice-body {
font-size: 11.5px;
color: var(--text-secondary);
line-height: 1.7;
letter-spacing: 0.01em;
}
.notice-link-btn {
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
font-family: 'NanumGothic', sans-serif;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.notice-link-btn:hover {
text-decoration: underline;
}
/* ── Dashboard ── */
.car-bg-radial {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 90% 70% at 60% 55%, rgba(25, 35, 70, 0.15) 0%, transparent 60%);
}
.version-label {
display: inline-flex;
align-items: center;
height: 16px;
font-size: 11px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.12em;
color: var(--text-soft);
}
.login-version-wrap {
position: absolute;
right: 24px;
bottom: 16px;
z-index: 5;
display: flex;
align-items: center;
gap: 10px;
}
.launcher-version-wrap {
position: absolute;
right: 24px;
bottom: 16px;
z-index: 5;
display: flex;
align-items: center;
gap: 10px;
}
.launcher-update-text {
display: inline-flex;
align-items: center;
position: relative;
top: -2px;
height: 16px;
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
font-family: 'NanumGothic', sans-serif;
font-size: 11px;
font-weight: 600;
line-height: 16px;
cursor: pointer;
}
.launcher-update-text:hover {
text-decoration: underline;
color: #6fa4ff;
}
.launcher-update-text:disabled {
cursor: default;
opacity: 0.55;
text-decoration: none;
}
/* ── Login ── */
.login-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center bottom;
opacity: 0;
animation: carFadeIn 1.4s 0.3s ease forwards;
z-index: 0;
}
.login-video-overlay {
position: absolute;
inset: 0;
background:
linear-gradient(to bottom, var(--bg-deep) 0%, rgba(10, 11, 14, 0.5) 35%, transparent 60%),
linear-gradient(to top, var(--bg-deep) 0%, transparent 40%),
linear-gradient(to right, rgba(10, 11, 14, 0.3) 0%, transparent 60%),
linear-gradient(to left, rgba(10, 11, 14, 0.5) 0%, transparent 55%),
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(10, 11, 14, 0.7) 0%, transparent 70%);
z-index: 1;
pointer-events: none;
}
.login-panel {
border-left: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(13, 15, 20, 0.6);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
.login-panel-edge {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 1px;
background: linear-gradient(to bottom, transparent, rgba(59, 127, 250, 0.2) 30%, rgba(59, 127, 250, 0.2) 70%, transparent);
pointer-events: none;
}
.login-panel-glow {
position: absolute;
top: 0; left: 0; right: 0;
height: 200px;
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(59, 127, 250, 0.06) 0%, transparent 100%);
pointer-events: none;
}
.animate-reveal {
animation: textReveal 0.8s 0.2s ease both;
}
.animate-reveal-late {
animation: textReveal 0.8s 0.3s ease both;
}
/* ── Login inputs ── */
.login-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.login-input-icon {
position: absolute;
left: 14px;
width: 15px;
height: 15px;
color: var(--text-muted);
pointer-events: none;
flex-shrink: 0;
}
.login-input-field {
width: 100%;
padding: 11px 16px 11px 40px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
}
.login-input-field::placeholder { color: var(--text-muted); }
.login-input-field:focus {
border-color: rgba(59, 127, 250, 0.5);
background: rgba(59, 127, 250, 0.04);
}
.profile-password-input {
width: 100%;
height: 36px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
caret-color: var(--text-primary);
}
.profile-password-input:focus {
border-color: rgba(59, 127, 250, 0.5);
background: rgba(59, 127, 250, 0.04);
}
.login-input-eye {
position: absolute;
right: 12px;
width: 16px;
height: 16px;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.login-input-eye:hover { color: var(--text-secondary); }
.login-input-eye svg { width: 16px; height: 16px; }
.login-checkbox {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
}
.login-google-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 0;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.login-google-btn:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.12);
}
/* ── Login ── */
.login-input {
width: 100%;
padding: 11px 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.2s, background 0.2s;
box-sizing: border-box;
}
.login-input::placeholder {
color: var(--text-muted);
}
.login-input:focus {
border-color: rgba(59, 127, 250, 0.5);
background: rgba(59, 127, 250, 0.04);
}
.login-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 11px 0;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: 6px;
color: #fff;
font-family: 'NanumGothic', sans-serif;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.3);
}
.login-btn:hover:not(:disabled) {
background: #5592ff;
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.5);
transform: translateY(-1px);
}
.login-btn:active:not(:disabled) {
transform: translateY(0);
}
/* ── Typing Cursor ── */
.typing-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--text-muted);
border-radius: 1px;
animation: cursorBlink 0.9s step-end infinite;
transform: translateY(2px);
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ── Running State ── */
.running-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 10px;
border-radius: 20px;
background: rgba(34, 197, 94, 0.07);
border: 1px solid rgba(34, 197, 94, 0.2);
margin-bottom: 24px;
}
.running-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5);
animation: running-pulse 1.8s ease infinite;
flex-shrink: 0;
}
@keyframes running-pulse {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
55% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
/* ── Animations ── */
@keyframes textReveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes carFadeIn {
from { opacity: 0; transform: translateX(30px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
+46
View File
@@ -0,0 +1,46 @@
export const EnvelopeIcon = (): React.JSX.Element => (
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
)
export const LockIcon = (): React.JSX.Element => (
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
)
export const PersonIcon = (): React.JSX.Element => (
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
)
export const EyeToggle = ({ show, onClick }: { show: boolean; onClick: () => void }): React.JSX.Element => (
<button type="button" onClick={onClick} className="login-input-eye">
{show ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
)
export const GoogleIcon = (): React.JSX.Element => (
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
)
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
import icon from '../assets/icon.png'
import { Minus, Square, X } from 'lucide-react'
import { useState } from 'react'
interface TitleBarProps {
appName?: string
}
function TitleBar({ appName }: TitleBarProps): React.JSX.Element {
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const handleMinimize = (): void => window.electron.ipcRenderer.send('window-minimize')
const handleMaximize = (): void => window.electron.ipcRenderer.send('window-maximize')
const handleClose = (): void => setShowCloseConfirm(true)
const handleConfirmClose = (): void => window.electron.ipcRenderer.send('window-close')
const winBtn =
'flex h-[20px] w-[20px] items-center justify-center text-white/50 hover:text-white transition cursor-pointer'
return (
<>
<div
className="flex h-[38px] shrink-0 items-center justify-between border-b border-white/10 bg-[#111520] px-4 pl-5 select-none"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
{/* 좌측 */}
<div className="flex items-center">
<img
src={icon}
alt="logo"
className="mr-2 h-[18px] w-auto -translate-y-[2px] opacity-90"
/>
<span className="font-['Rajdhani',sans-serif] text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">
{appName ?? 'Autonomy Studio'}
</span>
</div>
{/* 우측 */}
<div
className="flex items-center gap-3"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<button onClick={handleMinimize} className={winBtn}>
<Minus size={12} strokeWidth={2.5} />
</button>
<button onClick={handleMaximize} className={winBtn}>
<Square size={11} strokeWidth={2.5} />
</button>
<button
onClick={handleClose}
className={`${winBtn} hover:text-red-400`}
>
<X size={12} strokeWidth={2.5} />
</button>
</div>
</div>
{showCloseConfirm && (
<div
className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<div className="relative w-[320px] overflow-hidden rounded-lg border border-[rgba(255,255,255,0.18)] bg-[rgba(22,27,39,0.72)] shadow-[0_28px_90px_rgba(0,0,0,0.58),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-2xl">
<header className="flex h-[54px] items-center justify-between px-5">
<h2 className="text-[15px] font-bold text-[var(--text-primary)]">Autonomy Studio</h2>
<button
type="button"
onClick={() => setShowCloseConfirm(false)}
className="grid h-8 w-8 place-items-center rounded-md text-white/55 hover:bg-white/[0.06] hover:text-white cursor-pointer transition-colors"
>
<X size={18} />
</button>
</header>
<div className="px-5 py-5">
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
앱을 종료하시겠습니까?
</p>
</div>
<footer className="flex h-[58px] items-center justify-end gap-2 px-5">
<button
type="button"
onClick={() => setShowCloseConfirm(false)}
className="rounded-md border border-[rgba(255,255,255,0.12)] px-5 py-2 text-[12px] font-semibold text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white cursor-pointer transition-colors"
>
취소
</button>
<button
type="button"
onClick={handleConfirmClose}
className="rounded-md border border-red-400/35 bg-red-500/15 px-5 py-2 text-[12px] font-bold text-red-100 hover:border-red-400/50 hover:bg-red-500/24 cursor-pointer transition-colors"
>
종료
</button>
</footer>
</div>
</div>
)}
</>
)
}
export default TitleBar
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
type TooltipProps = {
label: string
children: ReactNode
side?: 'top' | 'bottom'
}
function Tooltip({ label, children, side = 'bottom' }: TooltipProps): React.JSX.Element {
const sideClass = side === 'top'
? 'bottom-[calc(100%+8px)]'
: 'top-[calc(100%+8px)]'
const arrowClass = side === 'top'
? 'bottom-[-4px] border-b border-r'
: 'top-[-4px] border-l border-t'
return (
<span className="group/tooltip relative inline-flex">
{children}
<span
role="tooltip"
className={`pointer-events-none absolute right-0 ${sideClass} z-[9999] whitespace-nowrap rounded-md border border-[rgba(255,255,255,0.1)] bg-[#111827] px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white/85 opacity-0 shadow-[0_14px_40px_rgba(0,0,0,0.35)] transition duration-150 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100`}
>
{label}
<span className={`absolute right-3 h-2 w-2 rotate-45 border-[rgba(255,255,255,0.1)] bg-[#111827] ${arrowClass}`} />
</span>
</span>
)
}
export default Tooltip
+51
View File
@@ -0,0 +1,51 @@
export interface LauncherApp {
appKey: string
displayName: string
description: string
subtitle?: string | null
detailUrl?: string | null
videoPath?: string | null
iconKey: string
launchUrl?: string | null
devAppPath?: string | null
execPath?: string | null
sortOrder?: number
canView?: boolean
canLaunch?: boolean
}
export const FALLBACK_APPS: LauncherApp[] = [
{
appKey: 'ADS IDE',
displayName: 'ADS IDE',
description: '클라우드 기반에서 자율주행 소프트웨어를 설계·빌드·실행까지 빠르게 수행할 수 있는 통합 개발 플랫폼으로, 오픈소스 ADS 기반의 유연한 구조를 통해 기능 수정과 배포가 가능하며 다양한 실차 및 시뮬레이션 환경을 지원합니다.',
subtitle: '자율주행 모빌리티 소프트웨어\n통합 개발 환경 (IDE)',
detailUrl: 'https://www.futuredrive.net/solutions/astudio',
videoPath: './video/ads_ide.mp4',
iconKey: 'code',
launchUrl: 'http://localhost:5175',
devAppPath: './apps/ads-ide/ads-ide-fe',
execPath: './ADS-IDE-1.1.0.AppImage',
sortOrder: 10,
},
{
appKey: 'LMS',
displayName: 'LMS',
description: '자율주행 교육 과정, 실습 콘텐츠, 사용자 학습 이력을 통합 관리하는 학습 관리 시스템입니다.',
subtitle: '자율주행 교육 콘텐츠\n학습 관리 시스템',
videoPath: './video/lms.mp4',
iconKey: 'graduation',
launchUrl: 'http://localhost:5176',
devAppPath: './apps/lms/lms-fe',
execPath: './LMS-1.0.0.AppImage',
sortOrder: 20,
},
{ appKey: 'Data Management', displayName: 'Data Management', description: '주행 데이터 수집 · 저장 · 분석 플랫폼', videoPath: './video/data_management.mp4', iconKey: 'bar-chart', sortOrder: 30 },
{ appKey: 'HMI', displayName: 'HMI', description: '차량 인터페이스 모니터링 및 제어', videoPath: './video/hmi.mp4', iconKey: 'monitor', sortOrder: 40 },
{ appKey: 'FMS', displayName: 'FMS', description: '플릿 운영 및 차량 관제 시스템', videoPath: './video/fms.mp4', iconKey: 'truck', sortOrder: 50 },
{ appKey: 'ADS Education', displayName: 'ADS Education', description: '자율주행 기술 학습 및 교육 콘텐츠', videoPath: './video/ads_education.mp4', iconKey: 'graduation', sortOrder: 60 },
{ appKey: 'AI Benchmark', displayName: 'AI Benchmark', description: 'AI 모델 성능 평가 및 비교 분석', videoPath: './video/ai_benchmark.mp4', iconKey: 'activity', sortOrder: 70 },
{ appKey: 'Calibration', displayName: 'Calibration', description: '센서 캘리브레이션 및 설정 도구', videoPath: './video/calibration.mp4', iconKey: 'sliders', sortOrder: 80 },
{ appKey: 'IP Repository', displayName: 'IP Repository', description: '지식재산 및 소프트웨어 자산 저장소', videoPath: './video/ip_repository.mp4', iconKey: 'shopping-cart', sortOrder: 90 },
{ appKey: 'HDMAP Builder', displayName: 'HDMAP Builder', description: '고정밀 지도 생성 및 편집 도구', videoPath: './video/hdmap_builder.mp4', iconKey: 'map', sortOrder: 100 },
]
+66
View File
@@ -0,0 +1,66 @@
/// <reference types="vite/client" />
import type { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
appWindowMeta: {
isAppWindow: boolean
appName: string
url: string
}
launcherTailscale?: {
status: () => Promise<{
connected: boolean
ip: string
backendState: string
online?: boolean
health?: string[]
loginName?: string
userName?: string
peers?: Array<{
id: string
hostName: string
dnsName?: string
os?: string
userName?: string
loginName?: string
ip: string
ips: string[]
online: boolean
active: boolean
lastSeen?: string
relay?: string
currentAddress?: string
}>
error?: string
}>
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
ok: boolean
message: string
}>
logout: () => Promise<{
ok: boolean
message: string
}>
}
launcherSshKey?: {
ensure: () => Promise<{
publicKey: string
publicKeyPath: string
privateKeyPath: string
}>
}
}
}
interface ImportMetaEnv {
readonly VITE_APP_VERSION: string
readonly VITE_ATNMY_AUTH_API_BASE_URL?: string
readonly VITE_ATNMY_STDO_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './assets/main.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+133
View File
@@ -0,0 +1,133 @@
import type { LauncherApp } from '../config/apps'
const DEFAULT_DETAIL_URL = 'https://www.futuredrive.net/solutions/astudio'
function resolvePackagedAssetPath(path?: string | null): string | null {
if (!path) return null
if (/^(https?:|file:|data:|blob:)/i.test(path)) return path
if (path.startsWith('/video/')) return `.${path}`
return path
}
interface AppViewProps {
app: LauncherApp
isRunning: boolean
onShowManual: () => void
onFocusApp: () => void
onCloseApp: () => void
}
function AppView({ app, isRunning, onShowManual, onFocusApp, onCloseApp }: AppViewProps): React.JSX.Element {
const [titleFirst, titleRest] = app.displayName.includes(' ')
? [app.displayName.split(' ')[0], app.displayName.split(' ').slice(1).join(' ')]
: [app.displayName, null]
const appTitle = titleRest
? <>{titleFirst}<br /><span>{titleRest}</span></>
: <span>{titleFirst}</span>
const videoPath = resolvePackagedAssetPath(app.videoPath)
if (isRunning) {
return (
<div className="flex-1 flex items-start relative overflow-hidden pt-[80px] pb-[80px] pl-[52px]">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_65%_55%_at_50%_45%,rgba(59,127,250,0.05)_0%,transparent_65%)]" />
<div className="text-content">
<h1 className="brand-title">
{appTitle}
</h1>
<div className="running-badge">
<span className="running-dot" />
<span className="text-[12px] font-medium tracking-[0.04em]" style={{ color: '#22c55e' }}>앱 실행 중</span>
</div>
<div className="divider" />
<p className="description" style={{ opacity: 0.55 }}>{app.description}</p>
<div className="mt-5 flex items-center gap-2">
<button
type="button"
onClick={onFocusApp}
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(59,127,250,0.35)] bg-[rgba(59,127,250,0.08)] text-[12px] text-[var(--accent)] hover:bg-[rgba(59,127,250,0.18)] hover:border-[rgba(59,127,250,0.6)] cursor-pointer transition-all duration-200"
>
앱 창으로 이동
</button>
<button
type="button"
onClick={onCloseApp}
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(255,80,80,0.2)] bg-[rgba(255,80,80,0.04)] text-[12px] hover:bg-[rgba(255,80,80,0.1)] hover:border-[rgba(255,80,80,0.35)] cursor-pointer transition-all duration-200"
style={{ color: 'rgba(255,110,110,0.75)' }}
>
앱 종료
</button>
</div>
</div>
</div>
)
}
return (
<div className="flex-1 flex items-start relative overflow-hidden pt-[80px] pb-[80px] pl-[52px]">
{/* 비디오 배경 */}
{videoPath && (
<>
<video
key={videoPath}
className="car-video"
src={videoPath}
autoPlay
loop
muted
playsInline
onError={(e) => { (e.target as HTMLVideoElement).style.display = 'none' }}
/>
<div className="car-video-shadow" />
<div className="floor-reflection" />
</>
)}
{!videoPath && (
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(59,127,250,0.04)_0%,transparent_70%)]" />
)}
{/* 텍스트 */}
<div className="text-content">
<h1 className="brand-title">
{appTitle}
</h1>
{app.subtitle && (
<p className="subtitle">
{app.subtitle.split('\n').map((line, i, arr) => (
<span key={i}>{line}{i < arr.length - 1 && <br />}</span>
))}
</p>
)}
<div className="divider" />
<p className="description">{app.description}</p>
<div className="mt-5 flex items-center gap-2">
<button
type="button"
onClick={() => {
window.open(app.detailUrl ?? DEFAULT_DETAIL_URL, '_blank', 'noopener,noreferrer')
}}
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(59,127,250,0.35)] bg-[rgba(59,127,250,0.08)] text-[12px] text-[var(--accent)] hover:bg-[rgba(59,127,250,0.18)] hover:border-[rgba(59,127,250,0.6)] cursor-pointer transition-all duration-200"
>
자세히 보기
</button>
<button
type="button"
onClick={onShowManual}
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(255,255,255,0.04)] text-[12px] text-[var(--text-secondary)] hover:bg-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.18)] cursor-pointer transition-all duration-200"
>
사용 매뉴얼
</button>
</div>
</div>
</div>
)
}
export default AppView
+20
View File
@@ -0,0 +1,20 @@
import TitleBar from '../components/TitleBar'
interface AppWindowShellProps {
appName: string
url: string
}
function AppWindowShell({ appName, url }: AppWindowShellProps): React.JSX.Element {
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-[#0a0b0e]">
<TitleBar appName={appName} />
<iframe
src={url}
style={{ flex: 1, border: 'none', display: 'block', background: '#0a0b0e' }}
/>
</div>
)
}
export default AppWindowShell
+758
View File
@@ -0,0 +1,758 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import TitleBar from '../components/TitleBar'
import Sidebar from '../components/Sidebar'
import Tooltip from '../components/Tooltip'
import AppView from './AppView'
import type { LauncherApp } from '../config/apps'
import type { AuthSession } from './LoginForm'
const version = import.meta.env.VITE_APP_VERSION
interface DashboardProps {
authSession: AuthSession
onSessionUpdate: (session: AuthSession) => void
onLogout: () => void
}
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
const IS_DEV = import.meta.env.DEV
const resolveHandoffAudienceApp = (appKey: string): string | null => {
const normalizedAppKey = appKey.trim().toLowerCase()
if (normalizedAppKey === 'lms') return 'LMS'
if (normalizedAppKey === 'ads ide' || normalizedAppKey === 'ads-ide') return 'ADS IDE'
return null
}
type AppNotice = {
id: string
date: string
title: string
body: string
linkUrl?: string | null
}
type AppUpdateAction = 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'
type AppUpdateStatus = {
appKey: string
displayName?: string
installed: boolean
localVersion: string | null
latestVersion: string | null
status: 'NOT_INSTALLED' | 'UPDATE_AVAILABLE' | 'READY' | 'DISABLED' | 'UNKNOWN_APP'
action: AppUpdateAction
updateRequired: boolean
downloadUrl?: string | null
releaseNotes?: string | null
canLaunch?: boolean
}
type InstallationInfo = {
installed: boolean
version: string | null
path: string | null
}
type AuthMeResponse = {
user?: AuthSession['user']
}
function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
const [apps, setApps] = useState<LauncherApp[]>([])
const [activeApp, setActiveApp] = useState('')
const [appNotices, setAppNotices] = useState<AppNotice[]>([])
const [readNoticeSignatures, setReadNoticeSignatures] = useState<Record<string, string>>(() => {
try {
const raw = window.localStorage.getItem(NOTICE_READ_STORAGE_KEY)
return raw ? JSON.parse(raw) as Record<string, string> : {}
} catch {
return {}
}
})
const [appStatus, setAppStatus] = useState<AppUpdateStatus | null>(null)
const [launcherStatus, setLauncherStatus] = useState<AppUpdateStatus | null>(null)
const [noticeOpen, setNoticeOpen] = useState(false)
const [updatingApps, setUpdatingApps] = useState<Record<string, boolean>>({})
const [updateProgressByApp, setUpdateProgressByApp] = useState<Record<string, number>>({})
const [localVersions, setLocalVersions] = useState<Record<string, string>>({})
const [installedOverrides, setInstalledOverrides] = useState<Record<string, boolean>>({})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [showManual, setShowManual] = useState(false)
const [runningApps, setRunningApps] = useState<Set<string>>(new Set())
const [launchingApp, setLaunchingApp] = useState<string | null>(null)
const noticeRef = useRef<HTMLDivElement>(null)
const initialAppSelectionDoneRef = useRef(false)
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
const hasNotices = appNotices.length > 0
const noticeSignature = appNotices.map((notice) => notice.id).join('|')
const hasUnreadNotices = hasNotices && readNoticeSignatures[activeApp] !== noticeSignature
const isRunning = runningApps.has(activeApp)
const activeLocalVersion = localVersions[activeApp]
const appAction = appStatus?.action ?? 'UNAVAILABLE'
const hasAppPermission = appConfig !== null && appConfig.canLaunch !== false && appStatus?.canLaunch !== false
const canLaunch = hasAppPermission && appAction === 'LAUNCH' && Boolean(appConfig?.launchUrl)
const hasAnyUpdatingApp = Object.values(updatingApps).some(Boolean)
const activeAppUpdating = Boolean(updatingApps[activeApp])
const activeAppLaunching = launchingApp === activeApp
const activeUpdateProgress = updateProgressByApp[activeApp] ?? 0
const launcherUpdateAvailable = launcherStatus?.action === 'UPDATE'
const launcherUpdateMessage = launcherUpdateAvailable
? `v${launcherStatus?.latestVersion ?? ''} 업데이트 가능`
: launcherStatus
? '최신버전'
: '업데이트 상태 확인중'
const refreshSession = useCallback(async (): Promise<AuthSession | null> => {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok) return null
const session = await response.json() as AuthSession
onSessionUpdate(session)
return session
}, [onSessionUpdate])
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
const request = (accessToken: string): Promise<Response> => fetch(url, {
...options,
headers: {
...(options.headers ?? {}),
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
})
let response = await request(authSession.accessToken)
if (response.status !== 401) return response
const refreshed = await refreshSession()
if (!refreshed) {
onLogout()
return response
}
response = await request(refreshed.accessToken)
return response
}, [authSession.accessToken, onLogout, refreshSession])
useEffect(() => {
let cancelled = false
const loadUserProfile = async (): Promise<void> => {
let session = authSession
let response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/me`, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
credentials: 'include',
})
if (response.status === 401) {
const refreshed = await refreshSession()
if (!refreshed) {
if (!cancelled) onLogout()
return
}
session = refreshed
response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/me`, {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
credentials: 'include',
})
}
if (!response.ok) return
const payload = await response.json().catch(() => null) as AuthMeResponse | null
if (!cancelled && payload?.user) {
const nextSession = {
...session,
user: payload.user,
}
if (JSON.stringify(nextSession.user) === JSON.stringify(authSession.user) && nextSession.accessToken === authSession.accessToken) {
return
}
onSessionUpdate({
...nextSession,
})
}
}
void loadUserProfile()
return () => {
cancelled = true
}
}, [authSession, onLogout, onSessionUpdate, refreshSession])
useEffect(() => {
let cancelled = false
fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/apps`)
.then((response) => response.ok ? response.json() : null)
.then((payload: { apps?: LauncherApp[] } | null) => {
if (cancelled || !payload?.apps?.length) return
setApps(payload.apps)
const currentApp = payload.apps.find((app) => app.appKey === activeApp)
const currentAppAvailable = currentApp?.canLaunch !== false && currentApp?.canView !== false
const firstAvailableApp = payload.apps.find((app) => app.canView !== false && app.canLaunch !== false)
const lastLaunchedAppKey = window.localStorage.getItem(LAST_LAUNCHED_APP_STORAGE_KEY)
const lastLaunchedApp = lastLaunchedAppKey
? payload.apps.find((app) => app.appKey === lastLaunchedAppKey && app.canView !== false && app.canLaunch !== false)
: undefined
if (!initialAppSelectionDoneRef.current) {
initialAppSelectionDoneRef.current = true
setActiveApp(lastLaunchedApp?.appKey ?? firstAvailableApp?.appKey ?? currentApp?.appKey ?? payload.apps[0].appKey)
return
}
if (!currentApp || !currentAppAvailable) {
setActiveApp(firstAvailableApp?.appKey ?? currentApp?.appKey ?? payload.apps[0].appKey)
}
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [activeApp, fetchWithAuthRefresh])
useEffect(() => {
setNoticeOpen(false)
setAppStatus(null)
setShowManual(false)
}, [activeApp])
useEffect(() => {
let cancelled = false
if (!activeApp) {
setAppNotices([])
return
}
fetch(`${LAUNCHER_API_BASE_URL}/api/notices/apps/${encodeURIComponent(activeApp)}`)
.then((response) => response.ok ? response.json() : null)
.then((payload: { notices?: AppNotice[] } | null) => {
if (!cancelled) setAppNotices(payload?.notices ?? [])
})
.catch(() => {
if (!cancelled) setAppNotices([])
})
return () => {
cancelled = true
}
}, [activeApp])
useEffect(() => {
if (!activeApp || !appConfig) {
setAppStatus(null)
return
}
let cancelled = false
const loadStatus = async (): Promise<void> => {
let installation: InstallationInfo = { installed: false, version: null, path: null }
try {
installation = await window.electron.ipcRenderer.invoke('get-app-installation', {
appName: activeApp,
execPath: appConfig.execPath,
devAppPath: appConfig.devAppPath,
}) as InstallationInfo
} catch {
installation = { installed: false, version: null, path: null }
}
if (IS_DEV && appConfig.devAppPath && installation.installed) {
if (!cancelled) {
setAppStatus({
appKey: activeApp,
displayName: appConfig.displayName,
installed: true,
localVersion: installation.version,
latestVersion: installation.version,
status: 'READY',
action: 'LAUNCH',
updateRequired: false,
})
}
return
}
const installed = installedOverrides[activeApp] ?? installation.installed
const currentVersion = installation.version ?? activeLocalVersion ?? ''
if (installation.version) {
setLocalVersions((prev) => ({ ...prev, [activeApp]: installation.version as string }))
}
const query = new URLSearchParams({
currentVersion,
installed: String(installed),
})
try {
const response = await fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/apps/${encodeURIComponent(activeApp)}/update?${query}`)
const status = response.ok ? await response.json() as AppUpdateStatus : null
if (!cancelled) setAppStatus(status)
} catch {
if (!cancelled) setAppStatus(null)
}
}
loadStatus()
return () => {
cancelled = true
}
}, [activeApp, activeLocalVersion, appConfig, fetchWithAuthRefresh, installedOverrides])
useEffect(() => {
let cancelled = false
const query = new URLSearchParams({
currentVersion: version,
installed: 'true',
})
fetch(`${LAUNCHER_API_BASE_URL}/api/apps/launcher/update?${query}`)
.then((response) => response.ok ? response.json() : null)
.then((status: AppUpdateStatus | null) => {
if (!cancelled) setLauncherStatus(status)
})
.catch(() => {
if (!cancelled) setLauncherStatus(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const handler = (_event: unknown, appName: string): void => {
setRunningApps((prev) => {
const next = new Set(prev)
next.delete(appName)
return next
})
}
window.electron.ipcRenderer.on('app-window-closed', handler)
return () => {
window.electron.ipcRenderer.removeListener('app-window-closed', handler)
}
}, [])
useEffect(() => {
const handler = (_event: unknown, payload: { appName: string; progress: number }): void => {
setUpdateProgressByApp((prev) => ({
...prev,
[payload.appName]: payload.progress,
}))
}
window.electron.ipcRenderer.on('app-download-progress', handler)
return () => {
window.electron.ipcRenderer.removeListener('app-download-progress', handler)
}
}, [])
const completeLocalUpdate = useCallback((appKey: string, latestVersion?: string | null): void => {
if (!latestVersion) return
setLocalVersions((prev) => ({ ...prev, [appKey]: latestVersion }))
setInstalledOverrides((prev) => ({ ...prev, [appKey]: true }))
}, [])
const markAppNoticesRead = useCallback((): void => {
if (!hasNotices) return
setReadNoticeSignatures((prev) => {
const next = {
...prev,
[activeApp]: noticeSignature,
}
window.localStorage.setItem(NOTICE_READ_STORAGE_KEY, JSON.stringify(next))
return next
})
}, [activeApp, hasNotices, noticeSignature])
const handleUpdate = useCallback(async (
targetApp: string,
latestVersion?: string | null,
downloadUrl?: string | null,
) => {
if (!downloadUrl) {
alert('다운로드 URL이 없습니다.')
return
}
if (hasAnyUpdatingApp) {
alert('이미 업데이트가 진행 중입니다.')
return
}
if (targetApp !== 'launcher' && !appConfig) {
alert('선택된 앱이 없습니다.')
return
}
setUpdatingApps((prev) => ({ ...prev, [targetApp]: true }))
setUpdateProgressByApp((prev) => ({ ...prev, [targetApp]: 0 }))
try {
if (targetApp === 'launcher') {
const result = await window.electron.ipcRenderer.invoke('install-launcher-update', {
downloadUrl,
latestVersion,
}) as InstallationInfo
const installedVersion = result.version ?? latestVersion
completeLocalUpdate(targetApp, installedVersion)
setLauncherStatus((prev) => prev
? {
...prev,
installed: true,
localVersion: installedVersion ?? prev.latestVersion,
status: 'READY',
action: 'LAUNCH',
updateRequired: false,
}
: prev)
} else {
const result = await window.electron.ipcRenderer.invoke('download-app', {
appName: targetApp,
downloadUrl,
execPath: appConfig?.execPath,
latestVersion,
}) as InstallationInfo
if (result.version) {
completeLocalUpdate(targetApp, result.version)
} else {
completeLocalUpdate(targetApp, latestVersion)
}
}
} catch (error) {
const message = error instanceof Error ? error.message : '다운로드에 실패했습니다.'
alert(message)
} finally {
setTimeout(() => {
setUpdatingApps((prev) => {
const next = { ...prev }
delete next[targetApp]
return next
})
setUpdateProgressByApp((prev) => {
const next = { ...prev }
delete next[targetApp]
return next
})
}, 500)
}
}, [appConfig, completeLocalUpdate, hasAnyUpdatingApp])
const createHandoffToken = useCallback(async (audienceApp: string): Promise<string | null> => {
const requestHandoff = async (accessToken: string): Promise<Response> => {
return fetch(`${LAUNCHER_API_BASE_URL}/api/auth/handoff`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ audienceApp }),
})
}
let response = await requestHandoff(authSession.accessToken)
if (response.status === 401) {
const refreshResponse = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (refreshResponse.ok) {
const nextSession = await refreshResponse.json() as AuthSession
onSessionUpdate(nextSession)
response = await requestHandoff(nextSession.accessToken)
} else {
alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.')
onLogout()
return null
}
}
if (!response.ok) {
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
const detail = payload?.message ?? payload?.error ?? `HTTP ${response.status}`
alert(`${audienceApp} 인증 연동에 실패했습니다. (${detail})`)
return null
}
const handoff = await response.json() as { handoffToken: string }
return handoff.handoffToken
}, [authSession.accessToken, onLogout, onSessionUpdate])
const handleLaunch = useCallback(async () => {
if (!appConfig || !canLaunch || launchingApp) return
setLaunchingApp(activeApp)
const launchDelay = new Promise((resolve) => window.setTimeout(resolve, 1500))
let handoffCode: string | undefined
try {
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
if (handoffAudienceApp) {
const [handoffToken] = await Promise.all([
createHandoffToken(handoffAudienceApp),
launchDelay,
])
handoffCode = handoffToken ?? undefined
if (!handoffCode) {
setLaunchingApp(null)
return
}
} else {
await launchDelay
}
window.electron.ipcRenderer.send('launch-app', {
appName: activeApp,
url: appConfig.launchUrl,
devAppPath: appConfig.devAppPath,
execPath: appConfig.execPath,
handoffCode,
audienceApp: resolveHandoffAudienceApp(activeApp) ?? activeApp,
})
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
setRunningApps((prev) => new Set([...prev, activeApp]))
} finally {
setLaunchingApp(null)
}
}, [activeApp, appConfig, canLaunch, createHandoffToken, launchingApp])
const handleFocusApp = useCallback(() => {
window.electron.ipcRenderer.send('focus-app-window', activeApp)
}, [activeApp])
const handleCloseApp = useCallback(() => {
window.electron.ipcRenderer.send('close-app-window', activeApp)
}, [activeApp])
useEffect(() => {
if (!noticeOpen) return
const handler = (e: MouseEvent): void => {
if (noticeRef.current && !noticeRef.current.contains(e.target as Node)) {
setNoticeOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [noticeOpen])
return (
<div className="w-screen h-screen overflow-hidden flex flex-col bg-[var(--bg-deep)]">
<TitleBar />
<div className="flex flex-1 overflow-hidden">
<Sidebar
apps={apps}
userName={authSession.user.name || authSession.user.email.split('@')[0] || '사용자'}
userEmail={authSession.user.email}
userRole={authSession.user.role}
userCreatedAt={authSession.user.createdAt}
userLastLoginAt={authSession.user.lastLoginAt}
primaryAffiliation={authSession.user.primaryAffiliation}
currentLicense={authSession.user.currentLicense}
activeApp={activeApp}
onAppChange={setActiveApp}
onLogout={onLogout}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
launcherUpdateAvailable={launcherUpdateAvailable}
launcherUpdating={Boolean(updatingApps.launcher)}
launcherUpdateMessage={launcherUpdateMessage}
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
authAccessToken={authSession.accessToken}
/>
<main className="main-content">
{/* 우상단 영역 */}
<div className="absolute top-[28px] right-[32px] z-10 flex items-start gap-3">
{!showManual && (
<>
{/* 공지사항 버튼 + 드롭다운 */}
<div className="relative" ref={noticeRef}>
<Tooltip label="공지사항">
<button
onClick={() => {
markAppNoticesRead()
setNoticeOpen((v) => !v)
}}
className="notice-icon-btn"
style={{ opacity: hasNotices ? 1 : 0.35, cursor: hasNotices ? 'pointer' : 'default' }}
disabled={!hasNotices}
>
{hasUnreadNotices && <span className="notice-badge" />}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</button>
</Tooltip>
{noticeOpen && hasNotices && (
<div className="notice-dropdown">
<div className="flex items-center justify-between mb-3 px-4 pt-4">
<div className="flex items-center gap-2">
<span className="notice-dot" />
<span className="notice-label">공지사항</span>
</div>
<span className="text-[11px] text-[var(--text-muted)]">{appNotices.length}건</span>
</div>
<div className="flex flex-col">
{appNotices.map((n, i) => (
<div key={n.id} className={`notice-item${i < appNotices.length - 1 ? ' notice-item-border' : ''}`}>
<div className="flex items-center justify-between mb-1">
<p className="notice-title">{n.title}</p>
<span className="notice-date">{n.date}</span>
</div>
<p className="notice-body">{n.body}</p>
{n.linkUrl && (
<button
type="button"
className="notice-link-btn"
onClick={() => window.open(n.linkUrl ?? '', '_blank', 'noopener,noreferrer')}
>
자세히보기
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* 업데이트 / 실행 버튼 — 앱 실행 중일 때는 숨김 */}
{!isRunning && hasAppPermission && (
appAction === 'INSTALL' || appAction === 'UPDATE' ? (
<div className="flex flex-col items-end gap-[7px]">
{activeAppUpdating ? (
<button className="updating-btn" disabled>
<svg 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>
{appAction === 'INSTALL' ? '설치중' : '업데이트중'}
</button>
) : (
<button className="update-btn" onClick={() => handleUpdate(activeApp, appStatus?.latestVersion, appStatus?.downloadUrl)} disabled={hasAnyUpdatingApp || !hasAppPermission}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{appAction === 'INSTALL' ? '설치' : '업데이트'}
</button>
)}
{activeAppUpdating && (
<div className="update-progress-wrap">
<div className="update-progress-track">
<div className="update-progress-fill" style={{ width: `${activeUpdateProgress}%` }} />
</div>
<span className="update-progress-pct">{activeUpdateProgress}%</span>
</div>
)}
</div>
) : canLaunch ? (
activeAppLaunching ? (
<button className="launch-pending-btn" disabled>
실행중
</button>
) : (
<button className="launch-btn" onClick={handleLaunch}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
실행
</button>
)
) : (
<button className="updating-btn" disabled>
{appStatus ? '미지원' : '확인중'}
</button>
)
)}
</>
)}
</div>
{/* 메인 콘텐츠 */}
{showManual ? (
<>
{/* 매뉴얼 헤더 바 */}
<div className="absolute top-0 left-0 right-0 h-[48px] flex items-center px-4 gap-3 border-b border-[var(--border)] bg-[var(--bg-sidebar)] z-10">
<button
onClick={() => setShowManual(false)}
className="flex items-center justify-center w-7 h-7 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span className="text-[13px] font-medium text-[var(--text-secondary)]">사용 매뉴얼</span>
</div>
<iframe
src="appdocs://host/install.html"
className="absolute left-0 right-0 bottom-0 border-none"
style={{ top: '48px', width: '100%', height: 'calc(100% - 48px)', background: '#0a0b0e' }}
/>
</>
) : appConfig ? (
<AppView
app={appConfig}
isRunning={isRunning}
onShowManual={() => setShowManual(true)}
onFocusApp={handleFocusApp}
onCloseApp={handleCloseApp}
/>
) : (
<div className="flex flex-1 items-center justify-center px-10 text-center">
<div>
<p className="text-[15px] font-medium text-[var(--text-secondary)]">연결된 앱이 없습니다.</p>
<p className="mt-2 text-[12px] text-[var(--text-muted)]">관리 콘솔에서 앱을 연결하면 이 화면에 표시됩니다.</p>
</div>
</div>
)}
{!showManual && (
<div className="launcher-version-wrap">
{launcherUpdateAvailable && (
<button
type="button"
className="launcher-update-text"
onClick={() => handleUpdate('launcher', launcherStatus.latestVersion, launcherStatus.downloadUrl)}
disabled={hasAnyUpdatingApp}
>
업데이트
</button>
)}
<div className="version-label">v{localVersions.launcher ?? version}</div>
</div>
)}
</main>
</div>
</div>
)
}
export default Dashboard
+276
View File
@@ -0,0 +1,276 @@
import { useState, useEffect } from 'react'
import TitleBar from '../components/TitleBar'
import LoginForm from './LoginForm'
import type { AuthSession } from './LoginForm'
import PasswordResetForm from './PasswordResetForm'
import SignupForm from './SignupForm'
type View = 'login' | 'signup' | 'password-reset'
type LoginNotice = {
id: string
title: string
body: string
linkUrl: string | null
}
type LauncherUpdateStatus = {
latestVersion: string | null
action: 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'
downloadUrl?: string | null
}
interface LoginProps {
onLogin: (session: AuthSession) => void
}
const version = import.meta.env.VITE_APP_VERSION
const TYPING_TEXT = '클라우드 기반 자율주행 모빌리티\n소프트웨어 개발, 검증 및 운영 환경.'
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const DISMISSED_LOGIN_NOTICE_KEY = 'atnmy.login.dismissedNoticeId'
function readDismissedLoginNoticeId(): string {
try {
return window.localStorage.getItem(DISMISSED_LOGIN_NOTICE_KEY) ?? ''
} catch {
return ''
}
}
function storeDismissedLoginNoticeId(noticeId: string): void {
try {
window.localStorage.setItem(DISMISSED_LOGIN_NOTICE_KEY, noticeId)
} catch {
// Notice dismissal should not break the login screen.
}
}
function Login({ onLogin }: LoginProps): React.JSX.Element {
const [view, setView] = useState<View>('login')
const [noticeDismissed, setNoticeDismissed] = useState(false)
const [notice, setNotice] = useState<LoginNotice | null>(null)
const [launcherStatus, setLauncherStatus] = useState<LauncherUpdateStatus | null>(null)
const [updating, setUpdating] = useState(false)
const [updateProgress, setUpdateProgress] = useState(0)
const [typedCount, setTypedCount] = useState(0)
const [loginLoading, setLoginLoading] = useState(false)
useEffect(() => {
let cancelled = false
fetch(`${LAUNCHER_API_BASE_URL}/api/notices/login`)
.then((response) => response.ok ? response.json() : null)
.then((payload: { notice?: LoginNotice | null } | null) => {
if (cancelled) return
const nextNotice = payload?.notice ?? null
setNotice(nextNotice && readDismissedLoginNoticeId() !== nextNotice.id ? nextNotice : null)
})
.catch(() => {
if (!cancelled) setNotice(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
const query = new URLSearchParams({
currentVersion: version,
installed: 'true',
})
fetch(`${LAUNCHER_API_BASE_URL}/api/apps/launcher/update?${query}`)
.then((response) => response.ok ? response.json() : null)
.then((status: LauncherUpdateStatus | null) => {
if (!cancelled) setLauncherStatus(status)
})
.catch(() => {
if (!cancelled) setLauncherStatus(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const handler = (_event: unknown, payload: { appName: string; progress: number }): void => {
if (payload.appName === 'launcher') setUpdateProgress(payload.progress)
}
window.electron.ipcRenderer.on('app-download-progress', handler)
return () => {
window.electron.ipcRenderer.removeListener('app-download-progress', handler)
}
}, [])
const handleLauncherUpdate = async (): Promise<void> => {
if (!launcherStatus?.downloadUrl) {
alert('다운로드 URL이 없습니다.')
return
}
setUpdating(true)
setUpdateProgress(0)
try {
await window.electron.ipcRenderer.invoke('install-launcher-update', {
downloadUrl: launcherStatus.downloadUrl,
latestVersion: launcherStatus.latestVersion,
})
} catch (error) {
const message = error instanceof Error ? error.message : '런처 업데이트에 실패했습니다.'
alert(message)
} finally {
setUpdating(false)
setUpdateProgress(0)
}
}
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined
let cancelled = false
const typeNext = (count: number): void => {
if (cancelled) return
setTypedCount(count)
if (count >= TYPING_TEXT.length) {
timer = setTimeout(() => typeNext(0), 3000)
return
}
timer = setTimeout(() => typeNext(count + 1), 55)
}
const start = setTimeout(() => typeNext(0), 900)
return () => {
cancelled = true
clearTimeout(start)
if (timer) clearTimeout(timer)
}
}, [])
return (
<div className="w-screen h-screen overflow-hidden flex flex-col bg-[var(--bg-deep)]">
<TitleBar />
<div className="flex flex-1 overflow-hidden relative">
{/* 플랫폼 공지 배너 — absolute로 레이아웃 영향 없음 */}
{notice && !noticeDismissed && (
<div className="absolute top-0 left-0 right-0 z-[100] flex items-center gap-3 px-5 py-[9px] bg-[rgba(22,27,42,0.92)] border-b border-[rgba(59,127,250,0.15)]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<p className="flex-1 text-[11.5px] text-[var(--text-secondary)] leading-none">
<span className="text-[var(--accent)] font-semibold mr-2">[{notice.title}]</span>
{notice.body}
{notice.linkUrl && (
<button
type="button"
onClick={() => {
window.open(notice.linkUrl ?? '', '_blank', 'noopener,noreferrer')
}}
className="ml-2 text-[var(--accent)] hover:underline cursor-pointer"
>
자세히 보기
</button>
)}
</p>
<button
type="button"
onClick={() => {
storeDismissedLoginNoticeId(notice.id)
setNoticeDismissed(true)
}}
className="shrink-0 flex items-center justify-center w-5 h-5 rounded text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
)}
{loginLoading && (
<div className="absolute inset-0 z-50 bg-[rgba(0,0,0,0.45)] pointer-events-none animate-[fadeIn_0.2s_ease]" />
)}
<div className="login-version-wrap">
{launcherStatus?.action === 'UPDATE' && (
<button
type="button"
className="launcher-update-text"
onClick={handleLauncherUpdate}
disabled={updating}
>
{updating ? `업데이트 ${updateProgress}%` : '업데이트'}
</button>
)}
<div className="version-label">v{version}</div>
</div>
{/* Left — 브랜딩 + 동영상 */}
<div className="flex-1 relative overflow-hidden">
<video className="login-video" src="./video/main_video.mp4" autoPlay loop muted playsInline />
<div className="login-video-overlay" />
<div className="absolute top-[72px] left-[52px] z-[2] animate-reveal">
<h1 className="brand-title" style={{ marginBottom: 14, fontSize: 44 }}>
Autonomy<br /><span>Studio</span>
</h1>
<div className="divider" style={{ marginBottom: 16 }} />
<p className="description">
{(() => {
const text = TYPING_TEXT.slice(0, typedCount)
const [line1, line2] = text.split('\n')
return (
<>
{line1}
{line2 !== undefined && <><br />{line2}</>}
<span className="typing-cursor" />
</>
)
})()}
</p>
</div>
</div>
{/* Right — 슬라이딩 폼 패널 */}
<div className="login-panel w-[420px] shrink-0 relative overflow-hidden">
<div className="login-panel-edge" />
<div className="login-panel-glow" />
<div
className="flex h-full transition-transform duration-[420ms] ease-in-out"
style={{
width: '1260px',
transform: view === 'login'
? 'translateX(0)'
: view === 'signup'
? 'translateX(-420px)'
: 'translateX(-840px)'
}}
>
<div className="w-[420px] flex items-center justify-center">
<LoginForm
onLogin={onLogin}
onGoSignup={() => setView('signup')}
onGoPasswordReset={() => setView('password-reset')}
onLoadingChange={setLoginLoading}
/>
</div>
<div className="w-[420px] flex items-center justify-center">
<SignupForm onGoLogin={() => setView('login')} onSignupComplete={onLogin} />
</div>
<div className="w-[420px] flex items-center justify-center">
<PasswordResetForm onGoLogin={() => setView('login')} />
</div>
</div>
</div>
</div>
</div>
)
}
export default Login
+380
View File
@@ -0,0 +1,380 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
interface LoginFormProps {
onLogin: (session: AuthSession) => void
onGoSignup: () => void
onGoPasswordReset: () => void
onLoadingChange: (v: boolean) => void
}
export interface AuthSession {
accessToken: string
accessTokenExpiresIn?: number
refreshToken?: string
refreshTokenExpiresIn?: number
user: {
id: string
email: string
name: string | null
role: string
roles?: string[]
createdAt?: string
lastLoginAt?: string | null
primaryAffiliation?: {
id: string
organizationId: string
organizationName: string
organizationType: string
department: string | null
position: string | null
} | null
currentLicense?: {
id: string
code: string
name: string
startsAt: string
expiresAt: string | null
} | null
}
}
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const MIN_LOGIN_DURATION_MS = 2000
const AUTH_SESSION_KEY = 'atnmy.auth.session'
const LAST_LOGIN_EMAIL_KEY = 'atnmy.auth.last-login-email'
const delay = (ms: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, ms))
type TailscaleProvisionResponse = {
installTailscale?: boolean
tailscaleAuthKey?: string
tailscaleLoginServer?: string
tailscaleHostname?: string
}
type PendingTailscaleConfirm = {
session: AuthSession
email: string
keepLogin: boolean
message: string
}
function extractProvisionErrorMessage(payload: unknown): string {
if (!payload) return ''
if (typeof payload === 'string') return payload
if (Array.isArray(payload)) return payload.map(extractProvisionErrorMessage).filter(Boolean).join(', ')
if (typeof payload !== 'object') return ''
const record = payload as Record<string, unknown>
const candidates = [record.message, record.details, record.error]
for (const candidate of candidates) {
const message = extractProvisionErrorMessage(candidate)
if (message && message !== '[object Object]' && !/^(bad gateway|internal server error|unauthorized|forbidden)$/i.test(message)) {
return message
}
}
try {
return JSON.stringify(payload)
} catch {
return ''
}
}
function normalizeHeadscaleAccount(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/@/g, '-at-')
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount: string): boolean {
if (!statusAccount) return false
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
}
function readLastLoginEmail(): string {
try {
return window.localStorage.getItem(LAST_LOGIN_EMAIL_KEY) ?? ''
} catch {
return ''
}
}
function storeLastLoginEmail(email: string): void {
try {
window.localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email)
} catch {
// Login should not fail if local storage is unavailable.
}
}
async function ensureLauncherTailscale(account: string, password: string): Promise<void> {
if (!window.launcherTailscale) return
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
const status = await window.launcherTailscale.status().catch(() => null)
const isSameAccount = Boolean(
status &&
(
isSameTailscaleAccount(status.loginName, account) ||
isSameTailscaleAccount(status.userName, account)
)
)
if (
status?.connected &&
isSameAccount
) {
return
}
if (
status?.backendState === 'Unavailable' &&
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
) {
return
}
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account,
password,
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
usageType: 'launcher-client',
publicSshKey: sshKey?.publicKey,
}),
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as unknown
const message = extractProvisionErrorMessage(payload)
throw new Error(message ? `Tailscale 연결 정보를 가져오지 못했습니다: ${message}` : 'Tailscale 연결 정보를 가져오지 못했습니다.')
}
const provision = await response.json() as TailscaleProvisionResponse
if (!provision.tailscaleAuthKey) {
throw new Error('Tailscale 인증키가 없습니다.')
}
const result = await window.launcherTailscale.login(
provision.tailscaleAuthKey,
provision.tailscaleLoginServer ?? '',
provision.tailscaleHostname,
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
)
if (!result.ok) {
const nextStatus = await window.launcherTailscale.status().catch(() => null)
if (nextStatus?.connected) return
throw new Error(result.message || 'Tailscale 연결에 실패했습니다.')
}
}
function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }: LoginFormProps): React.JSX.Element {
const [email, setEmail] = useState(() => readLastLoginEmail())
const [password, setPassword] = useState('qwer1234')
const [showPassword, setShowPassword] = useState(false)
const [keepLogin, setKeepLogin] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null)
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
storeLastLoginEmail(loginEmail.trim())
if (persistLogin) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
} else {
window.localStorage.removeItem(AUTH_SESSION_KEY)
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
}
const elapsed = Date.now() - startedAt
if (elapsed < MIN_LOGIN_DURATION_MS) {
await delay(MIN_LOGIN_DURATION_MS - elapsed)
}
onLogin(session)
}
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
if (emailError) return
setLoading(true)
setError('')
setPendingTailscaleConfirm(null)
onLoadingChange(true)
const startedAt = Date.now()
try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
throw new Error('이메일 또는 비밀번호를 확인하세요.')
}
const session = await response.json() as AuthSession
try {
await ensureLauncherTailscale(email, password)
} catch (tailscaleError) {
setLoading(false)
onLoadingChange(false)
setPendingTailscaleConfirm({
session,
email,
keepLogin,
message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.'
})
return
}
await completeLogin(session, email, keepLogin, startedAt)
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
} finally {
setLoading(false)
onLoadingChange(false)
}
}
const continueWithoutTailscale = async (): Promise<void> => {
if (!pendingTailscaleConfirm) return
const pending = pendingTailscaleConfirm
setPendingTailscaleConfirm(null)
setLoading(true)
setError('')
onLoadingChange(true)
try {
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
} catch (continueError) {
setError(continueError instanceof Error ? continueError.message : '로그인에 실패했습니다.')
} finally {
setLoading(false)
onLoadingChange(false)
}
}
return (
<>
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late">
<h2 className="text-[20px] font-bold text-[var(--text-primary)] mb-1">로그인</h2>
<p className="text-[12px] text-[var(--text-muted)] mb-6">계정에 로그인하여 서비스를 이용하세요.</p>
<div className="flex flex-col gap-3 mb-4">
<div>
<div className="login-input-wrap">
<EnvelopeIcon />
<input
className="login-input-field"
type="email"
placeholder="이메일"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showPassword ? 'text' : 'password'}
placeholder="비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<EyeToggle show={showPassword} onClick={() => setShowPassword((v) => !v)} />
</div>
</div>
<div className="flex items-center justify-between mb-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={keepLogin}
onChange={(e) => setKeepLogin(e.target.checked)}
className="login-checkbox"
/>
<span className="text-[12px] text-[var(--text-secondary)]">로그인 상태 유지</span>
</label>
<button type="button" onClick={onGoPasswordReset} className="text-[12px] text-[var(--accent)] hover:text-[#6aa0ff] hover:underline transition-colors cursor-pointer">
비밀번호 찾기
</button>
</div>
<div className="mb-3 flex h-5 items-center justify-center">
<p className={`text-center text-[12px] text-red-300 transition-opacity ${error ? 'opacity-100' : 'opacity-0'}`}>
{error || 'placeholder'}
</p>
</div>
<button type="submit" className="login-btn w-full mb-4" disabled={loading || Boolean(emailError)} style={{ opacity: loading || emailError ? 0.7 : 1 }}>
{loading ? '로그인 중...' : '로그인'}
</button>
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-[var(--border)]" />
<span className="text-[11px] text-[var(--text-muted)]">또는</span>
<div className="flex-1 h-px bg-[var(--border)]" />
</div>
<button type="button" className="login-google-btn w-full mb-6">
<GoogleIcon />
Google로 로그인
</button>
<p className="text-center text-[12px] text-[var(--text-muted)]">
계정이 없으신가요?{' '}
<button type="button" onClick={onGoSignup} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
회원가입
</button>
</p>
</form>
{pendingTailscaleConfirm && createPortal(
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/45 px-6 backdrop-blur-[2px]">
<section className="w-full max-w-[380px] overflow-hidden rounded-xl border border-white/14 bg-[#121722] shadow-[0_28px_80px_rgba(0,0,0,0.45)]">
<div className="border-b border-white/10 px-5 py-4">
<h3 className="text-[15px] font-bold text-white">Tailscale 연결 실패</h3>
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/62">
플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다.
</p>
</div>
<div className="px-5 py-4">
<div className="max-h-[96px] overflow-y-auto rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-[11px] font-medium leading-relaxed text-white/56">
{pendingTailscaleConfirm.message}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={() => setPendingTailscaleConfirm(null)}
className="h-9 cursor-pointer rounded-md border border-white/16 bg-white/6 px-4 text-[12px] font-bold text-white/72 transition hover:bg-white/10 hover:text-white"
>
취소
</button>
<button
type="button"
onClick={() => void continueWithoutTailscale()}
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea]"
>
계속 진행
</button>
</div>
</section>
</div>,
document.body
)}
</>
)
}
export default LoginForm
@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react'
import { EnvelopeIcon, EyeToggle, LockIcon } from '../components/AuthIcons'
interface PasswordResetFormProps {
onGoLogin: () => void
}
const AUTH_BASE_URL = (
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
).replace(/\/+$/, '')
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function PasswordResetForm({ onGoLogin }: PasswordResetFormProps): React.JSX.Element {
const [email, setEmail] = useState('')
const [verificationCode, setVerificationCode] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false)
const [codeSent, setCodeSent] = useState(false)
const [sendingCode, setSendingCode] = useState(false)
const [codeTimer, setCodeTimer] = useState(0)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
useEffect(() => {
if (codeTimer <= 0) return
const timer = setTimeout(() => setCodeTimer((value) => value - 1), 1000)
return () => clearTimeout(timer)
}, [codeTimer])
const handleSendCode = async (): Promise<void> => {
if (emailError) return
setSendingCode(true)
setVerificationCode('')
setError('')
setSuccess('')
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/password-reset/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email }),
})
if (response.status === 404) {
throw new Error('가입되지 않은 이메일입니다.')
}
if (!response.ok) throw new Error('인증코드 발송에 실패했습니다.')
setCodeSent(true)
setCodeTimer(60)
} catch (sendError) {
setCodeSent(false)
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.')
} finally {
setSendingCode(false)
}
}
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
setError('')
setSuccess('')
if (emailError) {
setError('올바른 이메일 형식으로 입력해주세요.')
return
}
if (!/^\d{6}$/.test(verificationCode)) {
setError('이메일 인증코드 6자리를 입력해주세요.')
return
}
if (password.length < 8) {
setError('비밀번호는 8자 이상이어야 합니다.')
return
}
if (password !== passwordConfirm) {
setError('비밀번호가 일치하지 않습니다.')
return
}
setSubmitting(true)
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/password-reset/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
email,
verificationCode,
password,
}),
})
if (response.status === 400) {
throw new Error('이메일 또는 인증코드를 확인해주세요.')
}
if (!response.ok) {
throw new Error('비밀번호 변경에 실패했습니다.')
}
setSuccess('비밀번호가 변경되었습니다. 새 비밀번호로 로그인하세요.')
setPassword('')
setPasswordConfirm('')
setVerificationCode('')
setCodeSent(false)
setCodeTimer(0)
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '비밀번호 변경에 실패했습니다.')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
<div className="flex items-center gap-2 mb-1">
<button
type="button"
onClick={onGoLogin}
className="flex items-center justify-center w-6 h-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors duration-200 cursor-pointer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
<h2 className="text-[20px] font-bold text-[var(--text-primary)]">비밀번호 찾기</h2>
</div>
<p className="text-[12px] text-[var(--text-muted)] mb-5">이메일 인증 후 새 비밀번호를 설정하세요.</p>
<div className="flex flex-col gap-3 mb-4">
<div>
<div className="login-input-wrap">
<EnvelopeIcon />
<input
className="login-input-field"
type="email"
placeholder="이메일을 입력해주세요."
value={email}
onChange={(e) => {
setEmail(e.target.value)
setCodeSent(false)
setSuccess('')
}}
required
/>
</div>
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
</div>
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: email.length > 0 ? (codeSent ? '238px' : '46px') : '0', opacity: email.length > 0 ? 1 : 0 }}
>
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<div className="login-input-wrap flex-1">
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
<input
className="login-input-field"
type="text"
placeholder="인증코드 6자리"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required={codeSent}
/>
</div>
<button
type="button"
disabled={sendingCode || codeTimer > 0 || !email || Boolean(emailError)}
onClick={handleSendCode}
className="verify-code-btn"
>
{sendingCode
? '발송 중...'
: codeTimer > 0
? `재발송 (${codeTimer}s)`
: codeSent ? '재발송' : '인증코드 발송'}
</button>
</div>
<div
className="transition-all duration-300 overflow-hidden text-[11px] leading-none"
style={{ maxHeight: codeSent ? '20px' : '0', opacity: codeSent ? 1 : 0, color: 'rgba(59,200,130,0.85)' }}
>
인증코드가 이메일로 발송되었습니다.
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showPassword ? 'text' : 'password'}
placeholder="새 비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={codeSent}
/>
<EyeToggle show={showPassword} onClick={() => setShowPassword((value) => !value)} />
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showPasswordConfirm ? 'text' : 'password'}
placeholder="새 비밀번호 확인"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required={codeSent}
/>
<EyeToggle show={showPasswordConfirm} onClick={() => setShowPasswordConfirm((value) => !value)} />
</div>
</div>
</div>
</div>
{success && <p className="mb-4 text-center text-[12px] text-[rgba(59,200,130,0.9)]">{success}</p>}
{error && <p className="mb-4 text-center text-[12px] text-red-300">{error}</p>}
<button type="submit" className="login-btn w-full mb-5" disabled={submitting || !codeSent} style={{ opacity: submitting || !codeSent ? 0.7 : 1 }}>
{submitting ? '변경 중...' : '비밀번호 변경'}
</button>
<p className="text-center text-[12px] text-[var(--text-muted)]">
비밀번호가 기억나셨나요?{' '}
<button type="button" onClick={onGoLogin} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
로그인
</button>
</p>
</form>
)
}
export default PasswordResetForm
+327
View File
@@ -0,0 +1,327 @@
import { useState, useEffect } from 'react'
import { EnvelopeIcon, LockIcon, PersonIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
import type { AuthSession } from './LoginForm'
interface SignupFormProps {
onGoLogin: () => void
onSignupComplete: (session: AuthSession) => void
}
const AUTH_BASE_URL = (
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
).replace(/\/+$/, '')
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const AUTH_SESSION_KEY = 'atnmy.auth.session'
function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX.Element {
const [name, setName] = useState('')
const [signupEmail, setSignupEmail] = useState('')
const [signupPassword, setSignupPassword] = useState('')
const [signupConfirm, setSignupConfirm] = useState('')
const [showSignupPw, setShowSignupPw] = useState(false)
const [showSignupConfirm, setShowSignupConfirm] = useState(false)
const [agreeTerms, setAgreeTerms] = useState(false)
const [agreePrivacy, setAgreePrivacy] = useState(false)
const [verifyCode, setVerifyCode] = useState('')
const [codeSent, setCodeSent] = useState(false)
const [verificationEmail, setVerificationEmail] = useState('')
const [sendingCode, setSendingCode] = useState(false)
const [codeTimer, setCodeTimer] = useState(0)
const [promoCode, setPromoCode] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const emailError = signupEmail.length > 0 && !EMAIL_PATTERN.test(signupEmail) ? '올바른 이메일 형식으로 입력해주세요.' : ''
const normalizedSignupEmail = signupEmail.trim().toLowerCase()
useEffect(() => {
if (codeTimer <= 0) return
const t = setTimeout(() => setCodeTimer((v) => v - 1), 1000)
return () => clearTimeout(t)
}, [codeTimer])
const handleSendCode = async (): Promise<void> => {
if (emailError) return
setSendingCode(true)
setVerifyCode('')
setVerificationEmail('')
setError('')
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/email-verification/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: signupEmail }),
})
if (response.status === 409) {
throw new Error('이미 가입된 이메일입니다.')
}
if (!response.ok) {
throw new Error('인증코드 발송에 실패했습니다.')
}
setCodeSent(true)
setVerificationEmail(normalizedSignupEmail)
setCodeTimer(60)
} catch (sendError) {
setCodeSent(false)
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.')
} finally {
setSendingCode(false)
}
}
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault()
setError('')
if (emailError) {
setError('올바른 이메일 형식으로 입력해주세요.')
return
}
if (signupPassword.length < 8) {
setError('비밀번호는 8자 이상이어야 합니다.')
return
}
if (signupPassword !== signupConfirm) {
setError('비밀번호가 일치하지 않습니다.')
return
}
if (!agreeTerms || !agreePrivacy) {
setError('필수 약관에 동의해주세요.')
return
}
if (!/^\d{6}$/.test(verifyCode)) {
setError('이메일 인증코드 6자리를 입력해주세요.')
return
}
if (!codeSent || verificationEmail !== normalizedSignupEmail) {
setError('현재 이메일로 인증코드를 다시 발송해주세요.')
return
}
setSubmitting(true)
try {
const response = await fetch(`${AUTH_BASE_URL}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name,
email: signupEmail,
password: signupPassword,
verificationCode: verifyCode,
}),
})
if (response.status === 409) {
throw new Error('이미 가입된 이메일입니다.')
}
if (response.status === 400) {
throw new Error('인증코드가 올바르지 않거나 만료되었습니다.')
}
if (!response.ok) {
throw new Error('회원가입에 실패했습니다.')
}
const session = await response.json() as AuthSession
window.localStorage.removeItem(AUTH_SESSION_KEY)
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
onSignupComplete(session)
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '회원가입에 실패했습니다.')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
<div className="flex items-center gap-2 mb-1">
<button
type="button"
onClick={onGoLogin}
className="flex items-center justify-center w-6 h-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors duration-200 cursor-pointer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
<h2 className="text-[20px] font-bold text-[var(--text-primary)]">회원가입</h2>
</div>
<p className="text-[12px] text-[var(--text-muted)] mb-5">새 계정을 만들어 서비스를 이용해보세요.</p>
<div className="flex flex-col gap-3 mb-4">
<div className="login-input-wrap">
<PersonIcon />
<input
className="login-input-field"
type="text"
placeholder="이름을 입력해주세요."
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<div className="login-input-wrap">
<EnvelopeIcon />
<input
className="login-input-field"
type="email"
placeholder="이메일을 입력해주세요."
value={signupEmail}
onChange={(e) => {
const nextEmail = e.target.value
const nextNormalizedEmail = nextEmail.trim().toLowerCase()
setSignupEmail(nextEmail)
if (verificationEmail && nextNormalizedEmail !== verificationEmail) {
setCodeSent(false)
setVerifyCode('')
setCodeTimer(0)
}
}}
required
/>
</div>
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
</div>
{/* 인증코드 — 이메일 입력 시 노출 */}
<div
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{ maxHeight: signupEmail.length > 0 ? (codeSent ? '78px' : '46px') : '0', opacity: signupEmail.length > 0 ? 1 : 0 }}
>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="login-input-wrap flex-1">
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
</svg>
<input
className="login-input-field"
type="text"
placeholder="인증코드 6자리"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required
/>
</div>
<button
type="button"
disabled={sendingCode || codeTimer > 0 || !signupEmail || Boolean(emailError)}
onClick={handleSendCode}
className="verify-code-btn"
>
{sendingCode
? '발송 중...'
: codeTimer > 0
? `재발송 (${codeTimer}s)`
: codeSent ? '재발송' : '인증코드 발송'}
</button>
</div>
{/* 발송 완료 안내 */}
<div
className="transition-all duration-300 overflow-hidden text-[11px] leading-none"
style={{ maxHeight: codeSent ? '20px' : '0', opacity: codeSent ? 1 : 0, color: 'rgba(59,200,130,0.85)' }}
>
인증코드가 이메일로 발송되었습니다.
</div>
</div>
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showSignupPw ? 'text' : 'password'}
placeholder="비밀번호를 입력해주세요."
value={signupPassword}
onChange={(e) => setSignupPassword(e.target.value)}
required
/>
<EyeToggle show={showSignupPw} onClick={() => setShowSignupPw((v) => !v)} />
</div>
<div className="login-input-wrap">
<LockIcon />
<input
className="login-input-field"
type={showSignupConfirm ? 'text' : 'password'}
placeholder="비밀번호를 다시 입력해주세요."
value={signupConfirm}
onChange={(e) => setSignupConfirm(e.target.value)}
required
/>
<EyeToggle show={showSignupConfirm} onClick={() => setShowSignupConfirm((v) => !v)} />
</div>
{/* 프로모션 코드 (선택) */}
<div className="login-input-wrap">
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
<line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
<input
className="login-input-field"
type="text"
placeholder="프로모션 코드 (선택)"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value)}
/>
</div>
</div>
{/* 약관 동의 */}
<div className="flex flex-col gap-[10px] mb-5">
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={agreeTerms} onChange={(e) => setAgreeTerms(e.target.checked)} className="login-checkbox" required />
<span className="text-[12px] text-[var(--text-secondary)]">
이용약관에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
</span>
</label>
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={agreePrivacy} onChange={(e) => setAgreePrivacy(e.target.checked)} className="login-checkbox" required />
<span className="text-[12px] text-[var(--text-secondary)]">
개인정보 처리방침에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
</span>
</label>
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
</div>
</div>
{error && <p className="mb-4 text-center text-[12px] text-red-300">{error}</p>}
<button type="submit" className="login-btn w-full mb-4" disabled={submitting} style={{ opacity: submitting ? 0.7 : 1 }}>
{submitting ? '가입 중...' : '회원가입'}
</button>
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-[var(--border)]" />
<span className="text-[11px] text-[var(--text-muted)]">또는</span>
<div className="flex-1 h-px bg-[var(--border)]" />
</div>
<button type="button" className="login-google-btn w-full mb-5">
<GoogleIcon />
Google로 회원가입
</button>
<p className="text-center text-[12px] text-[var(--text-muted)]">
이미 계정이 있나요?{' '}
<button type="button" onClick={onGoLogin} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
로그인
</button>
</p>
</form>
)
}
export default SignupForm