Compare commits

..

17 Commits

Author SHA1 Message Date
chyoo 8d6ce50e6d merge develop into release 2026-07-10 15:25:38 +09:00
chyoo a5ee4221c4 merge develop into release 2026-07-10 15:24:54 +09:00
chyoo d7316449c2 merge develop into release 2026-07-10 15:15:58 +09:00
chyoo de40ef4ebb merge develop into release 2026-07-08 11:53:21 +09:00
chyoo 6e67684579 merge develop into release 2026-07-08 11:49:10 +09:00
chyoo 1fda517a0b Merge branch 'develop' into release 2026-07-06 15:45:59 +09:00
chyoo 4c2b98f99f Merge branch 'develop' into release 2026-07-06 15:35:43 +09:00
chyoo 7dbd07db8c Merge branch 'develop' into release 2026-07-06 14:34:38 +09:00
chyoo 6b14c3928f Merge branch 'develop' into release 2026-07-06 14:32:50 +09:00
chyoo fb403a2b30 Merge branch 'develop' into release 2026-07-06 13:52:43 +09:00
chyoo 4af21c5af5 Merge branch 'develop' into release 2026-07-06 11:47:19 +09:00
chyoo 751d334743 Merge branch 'develop' into release 2026-07-06 11:02:25 +09:00
chyoo 785da57ab1 Merge branch 'develop' into release 2026-07-06 10:51:26 +09:00
chyoo feb3480598 Merge branch 'develop' into release 2026-07-06 09:55:19 +09:00
chyoo 4d95c94b4f Merge branch 'develop' into release 2026-07-06 09:30:28 +09:00
chyoo 3ea9842c03 Merge branch 'develop' into release 2026-07-03 17:50:40 +09:00
chyoo 4e2136f03f update launcher frontend 2026-07-03 17:43:22 +09:00
18 changed files with 128 additions and 2792 deletions
-19
View File
@@ -5,22 +5,3 @@ out/
.env.* .env.*
*.log *.log
.DS_Store .DS_Store
# Direct `tsc -p` output. Electron Vite owns application compilation.
/electron.vite.config.js
/electron.vite.config.d.ts
/src/main/*.js
/src/main/*.d.ts
/src/main/**/*.js
/src/main/**/*.d.ts
/src/preload/*.js
/src/preload/*.d.ts
/src/preload/**/*.js
/src/preload/**/*.d.ts
/src/renderer/src/*.js
/src/renderer/src/*.jsx
/src/renderer/src/*.d.ts
/src/renderer/src/**/*.js
/src/renderer/src/**/*.jsx
/src/renderer/src/**/*.d.ts
*.tsbuildinfo
-5
View File
@@ -8,13 +8,9 @@ const { version } = JSON.parse(readFileSync('./package.json', 'utf-8'))
export default defineConfig({ export default defineConfig({
main: { main: {
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
build: { rollupOptions: { input: resolve('src/main/index.ts') } },
plugins: [externalizeDepsPlugin()] plugins: [externalizeDepsPlugin()]
}, },
preload: { preload: {
resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.json'] },
build: { rollupOptions: { input: resolve('src/preload/index.ts') } },
plugins: [externalizeDepsPlugin()] plugins: [externalizeDepsPlugin()]
}, },
renderer: { renderer: {
@@ -23,7 +19,6 @@ export default defineConfig({
'import.meta.env.VITE_APP_VERSION': JSON.stringify(version) 'import.meta.env.VITE_APP_VERSION': JSON.stringify(version)
}, },
resolve: { resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'],
alias: { alias: {
'@renderer': resolve('src/renderer/src') '@renderer': resolve('src/renderer/src')
} }
+2 -19
View File
@@ -1,19 +1,18 @@
{ {
"name": "autonomy-studio-launcher", "name": "autonomy-studio-launcher",
"version": "1.0.27", "version": "1.0.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "autonomy-studio-launcher", "name": "autonomy-studio-launcher",
"version": "1.0.27", "version": "1.0.14",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"node-pty": "^1.1.0",
"tailwindcss": "^4.2.4" "tailwindcss": "^4.2.4"
}, },
"devDependencies": { "devDependencies": {
@@ -8308,22 +8307,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-pty/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.38", "version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+5 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "autonomy-studio-launcher", "name": "autonomy-studio-launcher",
"version": "1.1.5", "version": "1.0.24",
"description": "Autonomy Studio v2 Frontend", "description": "Autonomy Studio v2 Frontend",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
@@ -14,13 +14,13 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview --config electron.vite.config.ts", "start": "electron-vite preview",
"dev": "electron-vite dev --config electron.vite.config.ts", "dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build --config electron.vite.config.ts", "build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir", "build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win", "build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build --config electron.vite.config.ts && electron-builder --mac", "build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux" "build:linux": "npm run build && electron-builder --linux"
}, },
"build": { "build": {
@@ -58,7 +58,6 @@
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"node-pty": "^1.1.0",
"tailwindcss": "^4.2.4" "tailwindcss": "^4.2.4"
}, },
"devDependencies": { "devDependencies": {
-70
View File
@@ -1,70 +0,0 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { readFileSync } = require('node:fs')
const { join } = require('node:path')
const ts = require('typescript')
const source = readFileSync(join(__dirname, '../src/main/appImageFuse.ts'), 'utf8')
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
const api = {}
new Function('exports', compiled)(api)
const cache = 'libfuse.so.2 (libc6,x86-64) => /lib/x86_64-linux-gnu/libfuse.so.2'
function fixture(options = {}) {
let installed = Boolean(options.installed)
const calls = []
const prepare = api.createFusePreparation({
platform: options.platform ?? 'linux', arch: 'x64',
readOsRelease: () => `ID=ubuntu\nVERSION_ID="${options.version ?? '24.04'}"\nPRETTY_NAME="Ubuntu"`,
confirm: async () => { calls.push('confirm'); return options.accept !== false },
run: async (command, args) => {
calls.push([command, ...args])
if (command === '/sbin/ldconfig') return { stdout: installed ? cache : '', stderr: '' }
if (command === '/usr/bin/apt-cache') return { stdout: options.noCandidate ? 'Candidate: (none)' : 'Candidate: 2.9.9', stderr: '' }
if (command === 'pkexec') {
if (options.fail) throw new Error('authentication cancelled')
installed = options.stillMissing !== true
return { stdout: '', stderr: '' }
}
throw new Error(`Unexpected command ${command}`)
},
})
return { prepare, calls }
}
test('FUSE 3 and a foreign architecture do not satisfy FUSE 2', () => {
assert.equal(api.hasFuseLibrary(cache, 'x64'), true)
assert.equal(api.hasFuseLibrary(cache, 'arm64'), false)
assert.equal(api.hasFuseLibrary('libfuse3.so.3 (libc6,x86-64)', 'x64'), false)
})
test('existing library and non-Linux skip installation', async () => {
for (const options of [{ installed: true }, { platform: 'darwin' }]) {
const f = fixture(options)
await f.prepare()
assert.equal(f.calls.includes('confirm'), false)
}
})
test('Ubuntu versions choose the appropriate package and recheck installation', async () => {
for (const [version, pkg] of [['22.04', 'libfuse2'], ['24.04', 'libfuse2t64'], ['26.04', 'libfuse2t64']]) {
const f = fixture({ version })
await f.prepare()
assert.deepEqual(f.calls.find((call) => call[0] === 'pkexec'), ['pkexec', '/usr/bin/apt-get', 'install', '-y', '--no-remove', pkg])
assert.equal(f.calls.filter((call) => call[0] === '/sbin/ldconfig').length, 2)
}
})
test('cancel does not install and can be retried', async () => {
const f = fixture({ accept: false })
await assert.rejects(f.prepare(), /취소/)
await assert.rejects(f.prepare(), /취소/)
assert.equal(f.calls.some((call) => call[0] === 'pkexec'), false)
})
test('missing candidate, failed authentication and failed postcheck reject', async () => {
for (const options of [{ noCandidate: true }, { fail: true }, { stillMissing: true }]) {
await assert.rejects(fixture(options).prepare())
}
})
test('concurrent preparations share one prompt and installation', async () => {
const f = fixture()
await Promise.all([f.prepare(), f.prepare()])
assert.equal(f.calls.filter((call) => call === 'confirm').length, 1)
assert.equal(f.calls.filter((call) => call[0] === 'pkexec').length, 1)
})
-65
View File
@@ -1,65 +0,0 @@
export type FusePackage = 'libfuse2' | 'libfuse2t64'
export function parseOsRelease(content: string): Record<string, string> {
return Object.fromEntries(content.split('\n').flatMap((line) => {
const match = line.match(/^([A-Z_]+)=(.*)$/)
return match ? [[match[1], match[2].replace(/^(["'])(.*)\1$/, '$2')]] : []
}))
}
export function fusePackageCandidates(os: Record<string, string>): FusePackage[] {
const modern = (os.ID === 'ubuntu' && Number.parseFloat(os.VERSION_ID) >= 24.04)
|| (os.ID === 'debian' && Number.parseFloat(os.VERSION_ID) >= 13)
return modern ? ['libfuse2t64', 'libfuse2'] : ['libfuse2', 'libfuse2t64']
}
export function hasFuseLibrary(cache: string, arch: string): boolean {
const architecture = arch === 'x64' ? 'x86-64' : arch === 'arm64' ? 'AArch64' : null
return cache.split('\n').some((line) => /^\s*libfuse\.so\.2\s/.test(line)
&& (!architecture || line.includes(architecture)))
}
type Dependencies = {
platform: string
arch: string
readOsRelease: () => string
run: (command: string, args: string[], timeout?: number) => Promise<{ stdout: string; stderr: string }>
confirm: (details: string) => Promise<boolean>
}
export function createFusePreparation(deps: Dependencies): () => Promise<void> {
let pending: Promise<void> | null = null
const ready = async (): Promise<boolean> => {
const result = await deps.run('/sbin/ldconfig', ['-p'], 10000)
return hasFuseLibrary(result.stdout, deps.arch)
}
const prepare = async (): Promise<void> => {
if (deps.platform !== 'linux' || await ready()) return
const os = parseOsRelease(deps.readOsRelease())
if (![os.ID, ...(os.ID_LIKE ?? '').split(/\s+/)].some((id) => id === 'ubuntu' || id === 'debian')) {
throw new Error('LMS 실행에 libfuse.so.2가 필요합니다. 이 운영체제에서는 자동 설치를 지원하지 않습니다. 배포판의 FUSE 2 라이브러리를 설치한 뒤 다시 실행하세요.')
}
let selected: FusePackage | undefined
for (const candidate of fusePackageCandidates(os)) {
const result = await deps.run('/usr/bin/apt-cache', ['policy', candidate], 10000)
if (/Candidate:\s*(?!\(none\))\S+/.test(result.stdout)) {
selected = candidate
break
}
}
if (!selected) throw new Error('FUSE 2 설치 패키지를 찾지 못했습니다. 시스템 패키지 목록과 Ubuntu 저장소 설정을 확인한 뒤 다시 시도하세요.')
if (!await deps.confirm(`${os.PRETTY_NAME ?? os.ID}\n\nLMS 실행에 필요한 ${selected} 라이브러리가 없습니다. 설치를 누르면 관리자 인증 후 설치합니다.`)) {
throw new Error('필수 구성요소 설치를 취소했습니다. 다음 설치 또는 실행 시 다시 진행할 수 있습니다.')
}
try {
await deps.run('pkexec', ['/usr/bin/apt-get', 'install', '-y', '--no-remove', selected], 600000)
} catch (error) {
throw new Error(`필수 구성요소 설치가 완료되지 않았습니다. 관리자 인증 취소, 네트워크 또는 패키지 관리자 잠금 상태를 확인하세요.\n${error instanceof Error ? error.message : String(error)}`)
}
if (!await ready()) throw new Error('설치 후에도 libfuse.so.2를 확인하지 못했습니다. 시스템 패키지 상태를 확인한 뒤 다시 시도하세요.')
}
return () => {
if (!pending) pending = prepare().finally(() => { pending = null })
return pending
}
}
+27 -201
View File
@@ -5,8 +5,6 @@ import { get as httpGet } from 'http'
import { get as httpsGet } from 'https' import { get as httpsGet } from 'https'
import { spawn, exec, execFile, ChildProcess } from 'child_process' import { spawn, exec, execFile, ChildProcess } from 'child_process'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { LmsWebBridge } from './lms-web-bridge'
import { createFusePreparation } from './appImageFuse'
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } } { scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
@@ -14,36 +12,6 @@ protocol.registerSchemesAsPrivileged([
let splash: BrowserWindow | null = null let splash: BrowserWindow | null = null
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
const lmsWebBridge = new LmsWebBridge()
const prepareLmsFuse = createFusePreparation({
platform: process.platform,
arch: process.arch,
readOsRelease: () => readFileSync('/etc/os-release', 'utf8'),
run: (command, args, timeout) => runCommand(command, args, timeout, { LC_ALL: 'C', LANG: 'C' }),
confirm: async (detail) => {
const options = {
type: 'info' as const,
title: 'LMS 실행 환경 준비',
message: '필수 구성요소를 설치하시겠습니까?',
detail,
buttons: ['설치', '취소'],
defaultId: 0,
cancelId: 1,
noLink: true,
}
const parent = BrowserWindow.getFocusedWindow()
const result = parent ? await dialog.showMessageBox(parent, options) : await dialog.showMessageBox(options)
return result.response === 0
},
})
async function prepareAppRuntime(payload: { appName: string; execPath?: string; devAppPath?: string }): Promise<void> {
if (payload.appName.trim().toLowerCase() !== 'lms') return
const devPath = resolveDevAppPath(payload.devAppPath) ?? getDefaultDevAppPath(payload.appName)
if (is.dev && devPath && isRunnableDevAppPath(devPath)) return
const path = resolveExecPath(payload.execPath, payload.appName)
if (path?.toLowerCase().endsWith('.appimage')) await prepareLmsFuse()
}
type CommandResult = { type CommandResult = {
ok: boolean ok: boolean
@@ -350,17 +318,12 @@ function downloadFile(
const total = Number(response.headers['content-length'] ?? 0) const total = Number(response.headers['content-length'] ?? 0)
let received = 0 let received = 0
let lastProgress = -1
const file = createWriteStream(tmpPath) const file = createWriteStream(tmpPath)
response.on('data', (chunk: Buffer) => { response.on('data', (chunk: Buffer) => {
received += chunk.length received += chunk.length
if (total > 0) { if (total > 0) {
const progress = Math.min(99, Math.round((received / total) * 100)) onProgress(Math.min(99, Math.round((received / total) * 100)))
if (progress !== lastProgress) {
lastProgress = progress
onProgress(progress)
}
} }
}) })
@@ -439,41 +402,18 @@ function createCleanChildEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return env return env
} }
function terminateChildProcess(child: ChildProcess): void {
if (child.killed) return
const kill = (signal: NodeJS.Signals): boolean => {
if (process.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid, signal)
return true
} catch {
// Fall back to the direct child when a process group is unavailable.
}
}
child.kill(signal)
return false
}
const killedProcessGroup = kill('SIGTERM')
setTimeout(() => {
if (killedProcessGroup && process.platform !== 'win32' && child.pid) {
try {
process.kill(-child.pid, 'SIGKILL')
return
} catch {
// The group already exited.
}
}
if (!child.killed) child.kill('SIGKILL')
}, 1500).unref()
}
function stopChildApps( function stopChildApps(
appProcesses: Map<string, ChildProcess>, appProcesses: Map<string, ChildProcess>,
appWindows: Map<string, BrowserWindow>, appWindows: Map<string, BrowserWindow>,
): void { ): void {
for (const child of appProcesses.values()) terminateChildProcess(child) for (const child of appProcesses.values()) {
if (!child.killed) {
child.kill('SIGTERM')
setTimeout(() => {
if (!child.killed) child.kill('SIGKILL')
}, 1500).unref()
}
}
appProcesses.clear() appProcesses.clear()
for (const win of appWindows.values()) { for (const win of appWindows.values()) {
@@ -547,38 +487,6 @@ function tailscaleCommandEnv(loginServer: string): NodeJS.ProcessEnv | undefined
return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined
} }
function getLauncherOsUser(): string {
const user = (process.env.USER ?? process.env.LOGNAME ?? '').trim()
return /^[a-z_][a-z0-9_-]*\$?$/i.test(user) ? user : ''
}
async function ensureTailscaleOperator(): Promise<CommandResult> {
const operator = getLauncherOsUser()
if (!operator) {
return { ok: false, message: '현재 OS 사용자를 확인하지 못했습니다.' }
}
const args = ['set', `--operator=${operator}`]
try {
await runCommand('tailscale', args, 15000)
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
} catch (error) {
if (!isTailscalePermissionError(error)) {
return { ok: false, message: commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패') }
}
try {
await runCommand('pkexec', ['/usr/bin/tailscale', ...args], 30000)
return { ok: true, message: 'Tailscale 제어 권한이 준비되었습니다.' }
} catch (pkexecError) {
return {
ok: false,
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한 설정 실패')),
}
}
}
}
async function installTailscale(): Promise<CommandResult> { async function installTailscale(): Promise<CommandResult> {
const installScript = [ const installScript = [
'set -e', 'set -e',
@@ -657,6 +565,13 @@ async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise<vo
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv) await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
} }
async function resetTailscaleSessionWithPkexec(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
const loggedOut = await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'logout'], 30000, commandEnv)
if (!loggedOut) {
await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'down'], 30000, commandEnv)
}
}
async function readTailscaleStatus(): Promise<TailscaleStatusResult> { async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
const attempts: Array<[string, string[]]> = [ const attempts: Array<[string, string[]]> = [
['tailscale', ['status', '--json']], ['tailscale', ['status', '--json']],
@@ -776,24 +691,16 @@ async function loginTailscale(
} catch (error) { } catch (error) {
if (isTailscalePermissionError(error)) { if (isTailscalePermissionError(error)) {
try { try {
if (forceReset) {
await resetTailscaleSessionWithPkexec(commandEnv)
}
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv) await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
await waitForTailscaleConnected() await waitForTailscaleConnected()
return { ok: true, message: 'Tailscale 연결 완료' }
// Persist unprivileged access after the one privileged recovery login.
const operator = await ensureTailscaleOperator()
return {
ok: true,
message: operator.ok
? 'Tailscale 연결 완료'
: 'Tailscale 연결 완료. 다음 연결을 위해 제어 권한을 다시 확인하세요.',
}
} catch (pkexecError) { } catch (pkexecError) {
return { return {
ok: false, ok: false,
message: commandErrorMessage( message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다.')),
pkexecError,
commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다. 다시 연결해 주세요.'),
),
} }
} }
} }
@@ -840,23 +747,6 @@ async function logoutTailscale(): Promise<CommandResult> {
} }
} }
async function restartTailscaleService(): Promise<CommandResult> {
try {
await runCommand('pkexec', ['/usr/bin/systemctl', 'restart', 'tailscaled'], 60000)
await delay(1000)
const status = await readTailscaleStatus()
return {
ok: true,
message: status.connected ? '원격 연결 서비스가 재시작되었습니다.' : '원격 연결 서비스가 재시작되었습니다. 연결 상태를 다시 확인하세요.',
}
} catch (error) {
return {
ok: false,
message: commandErrorMessage(error, '원격 연결 서비스 재시작 실패'),
}
}
}
function createSplashWindow(): void { function createSplashWindow(): void {
splash = new BrowserWindow({ splash = new BrowserWindow({
width: 580, width: 580,
@@ -907,9 +797,7 @@ function createWindow(): void {
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: false, sandbox: false
// Keep token refresh and launcher background work running while child apps are open.
backgroundThrottling: false
} }
}) })
attachDevToolsShortcut(mainWindow) attachDevToolsShortcut(mainWindow)
@@ -925,10 +813,6 @@ function createWindow(): void {
} }
}, 1500) }, 1500)
}) })
mainWindow.on('close', () => {
lmsWebBridge.invalidateSessions()
stopChildApps(appProcesses, appWindows)
})
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url) shell.openExternal(details.url)
@@ -987,10 +871,6 @@ function createWindow(): void {
const targetPath = resolveExecPath(payload.execPath, payload.appName) const targetPath = resolveExecPath(payload.execPath, payload.appName)
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.') if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
if (payload.appName.trim().toLowerCase() === 'lms' && targetPath.toLowerCase().endsWith('.appimage')) {
await prepareLmsFuse()
}
await downloadFile(payload.downloadUrl, targetPath, (progress) => { await downloadFile(payload.downloadUrl, targetPath, (progress) => {
event.sender.send('app-download-progress', { event.sender.send('app-download-progress', {
appName: payload.appName, appName: payload.appName,
@@ -1074,32 +954,8 @@ function createWindow(): void {
ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus()) ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus())
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache()) ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url)) ipcMain.handle('start-google-login', (_, url: string) => shell.openExternal(url))
ipcMain.handle('launch-web-app', async (_event, payload: { appName?: string; url?: string }) => {
const rawUrl = payload.url?.trim()
if (!rawUrl) throw new Error('웹 실행 주소가 없습니다.')
try {
const url = new URL(rawUrl)
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('웹 실행 주소 형식이 올바르지 않습니다.')
}
if (payload.appName?.trim().toLowerCase() === 'lms') {
const bridge = await lmsWebBridge.createSession(url.origin)
const hash = new URLSearchParams(url.hash.replace(/^#/, ''))
hash.set('bridgeUrl', bridge.url)
hash.set('bridgeToken', bridge.token)
url.hash = hash.toString()
}
await shell.openExternal(url.toString())
mainWindow.minimize()
return { ok: true }
} catch (error) {
throw new Error(error instanceof Error ? error.message : '웹 브라우저를 열지 못했습니다.')
}
})
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus()) ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
ipcMain.handle('launcher-tailscale-install', () => installTailscale()) ipcMain.handle('launcher-tailscale-install', () => installTailscale())
ipcMain.handle('launcher-tailscale-ensure-operator', () => ensureTailscaleOperator())
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey()) ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
ipcMain.handle( ipcMain.handle(
'launcher-tailscale-login', 'launcher-tailscale-login',
@@ -1107,7 +963,6 @@ function createWindow(): void {
loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)), loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)),
) )
ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale()) ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale())
ipcMain.handle('launcher-tailscale-restart-service', () => restartTailscaleService())
const onAppClosed = (appName: string): void => { const onAppClosed = (appName: string): void => {
mainWindow.webContents.send('app-window-closed', appName) mainWindow.webContents.send('app-window-closed', appName)
@@ -1117,28 +972,7 @@ function createWindow(): void {
} }
} }
ipcMain.handle('launcher-end-user-session', () => { ipcMain.on('launch-app', (_, payload: {
lmsWebBridge.invalidateSessions()
for (const [appName, win] of appWindows) {
if (appName.trim().toLowerCase() !== 'lms') continue
if (!win.isDestroyed()) win.close()
}
for (const [appName, child] of appProcesses) {
if (appName.trim().toLowerCase() !== 'lms' || child.killed) continue
terminateChildProcess(child)
}
return { ok: true }
})
ipcMain.handle('prepare-app-runtime', async (_event, payload: { appName: string; execPath?: string; devAppPath?: string }) => {
await prepareAppRuntime(payload)
return { ok: true }
})
ipcMain.on('launch-app', async (_, payload: {
appName: string appName: string
url?: string url?: string
width?: number width?: number
@@ -1148,13 +982,6 @@ function createWindow(): void {
handoffCode?: string handoffCode?: string
audienceApp?: string audienceApp?: string
}) => { }) => {
try {
await prepareAppRuntime(payload)
} catch (error) {
mainWindow.webContents.send('app-window-closed', payload.appName)
dialog.showErrorBox('LMS 실행 환경 준비 실패', error instanceof Error ? error.message : String(error))
return
}
const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload
const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined
const authLaunchArgs = handoffCode const authLaunchArgs = handoffCode
@@ -1203,9 +1030,12 @@ function createWindow(): void {
if (spawnPath) { if (spawnPath) {
const useDevLauncher = is.dev && spawnPath === resolvedDevAppPath const useDevLauncher = is.dev && spawnPath === resolvedDevAppPath
const launchUrlArg = url ? [`--url=${encodeURIComponent(url)}`] : [] const launchUrlArg = url ? [`--url=${encodeURIComponent(url)}`] : []
const noSandboxArg = !useDevLauncher && process.platform === 'linux' && spawnPath.toLowerCase().endsWith('.appimage')
? ['--no-sandbox']
: []
const args = useDevLauncher const args = useDevLauncher
? ['run', 'dev'] ? ['run', 'dev']
: [...launchUrlArg, ...authLaunchArgs, ...(authProtocolUrl ? [authProtocolUrl] : [])] : [...noSandboxArg, ...launchUrlArg, ...authLaunchArgs, ...(authProtocolUrl ? [authProtocolUrl] : [])]
const execBin = useDevLauncher ? 'npm' : spawnPath const execBin = useDevLauncher ? 'npm' : spawnPath
const childEnv = createCleanChildEnv() const childEnv = createCleanChildEnv()
@@ -1390,8 +1220,4 @@ if (!gotSingleInstanceLock) {
app.quit() app.quit()
} }
}) })
app.on('will-quit', () => {
void lmsWebBridge.stop()
})
} }
File diff suppressed because it is too large Load Diff
-8
View File
@@ -14,11 +14,9 @@ const appWindowMeta = { isAppWindow, appName, url: appUrl }
const launcherTailscale = { const launcherTailscale = {
status: () => ipcRenderer.invoke('launcher-tailscale-status'), status: () => ipcRenderer.invoke('launcher-tailscale-status'),
install: () => ipcRenderer.invoke('launcher-tailscale-install'), install: () => ipcRenderer.invoke('launcher-tailscale-install'),
ensureOperator: () => ipcRenderer.invoke('launcher-tailscale-ensure-operator'),
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset), ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'), logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
restartService: () => ipcRenderer.invoke('launcher-tailscale-restart-service'),
} }
const launcherSshKey = { const launcherSshKey = {
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'), ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
@@ -31,9 +29,6 @@ const launcherAuth = {
return () => ipcRenderer.removeListener('google-oauth-callback', listener) return () => ipcRenderer.removeListener('google-oauth-callback', listener)
}, },
} }
const launcherSession = {
endUserSession: () => ipcRenderer.invoke('launcher-end-user-session'),
}
if (process.contextIsolated) { if (process.contextIsolated) {
try { try {
@@ -42,7 +37,6 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale) contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey) contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth) contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('launcherSession', launcherSession)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
@@ -57,6 +51,4 @@ if (process.contextIsolated) {
window.launcherSshKey = launcherSshKey window.launcherSshKey = launcherSshKey
// @ts-ignore // @ts-ignore
window.launcherAuth = launcherAuth window.launcherAuth = launcherAuth
// @ts-ignore
window.launcherSession = launcherSession
} }
+35 -87
View File
@@ -1,14 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import Login from './pages/Login' import Login from './pages/Login'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import AppWindowShell from './pages/AppWindowShell' import AppWindowShell from './pages/AppWindowShell'
import type { AuthSession } from './pages/LoginForm' import type { AuthSession } from './pages/LoginForm'
import {
blockLauncherSessionRefresh,
refreshLauncherSession,
resetLauncherSessionRefresh,
} from './auth/sessionRefresh'
const AUTH_SESSION_KEY = 'atnmy.auth.session' const AUTH_SESSION_KEY = 'atnmy.auth.session'
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '') const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
@@ -22,8 +16,6 @@ function App(): React.JSX.Element {
return null return null
} }
}) })
const [sessionExpiredOpen, setSessionExpiredOpen] = useState(false)
const sessionExpiredRef = useRef(false)
const meta = window.appWindowMeta const meta = window.appWindowMeta
if (meta?.isAppWindow) { if (meta?.isAppWindow) {
@@ -31,22 +23,13 @@ function App(): React.JSX.Element {
} }
const handleSessionUpdate = useCallback((session: AuthSession): void => { const handleSessionUpdate = useCallback((session: AuthSession): void => {
if (sessionExpiredRef.current) return setAuthSession(session)
const nextSession: AuthSession = {
...session,
user: {
...session.user,
loginMethod: session.user.loginMethod ?? authSession?.user.loginMethod,
},
}
setAuthSession(nextSession)
if (window.localStorage.getItem(AUTH_SESSION_KEY)) { if (window.localStorage.getItem(AUTH_SESSION_KEY)) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(nextSession)) window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
} else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) { } else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) {
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(nextSession)) window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
} }
}, [authSession?.user.loginMethod]) }, [])
const requestLogout = useCallback((accessToken?: string): void => { const requestLogout = useCallback((accessToken?: string): void => {
void fetch(`${LAUNCHER_API_BASE_URL}/api/auth/logout`, { void fetch(`${LAUNCHER_API_BASE_URL}/api/auth/logout`, {
@@ -58,93 +41,58 @@ function App(): React.JSX.Element {
}, []) }, [])
const handleLogout = useCallback((): void => { const handleLogout = useCallback((): void => {
blockLauncherSessionRefresh()
window.launcherSession?.endUserSession().catch(() => undefined)
requestLogout(authSession?.accessToken) requestLogout(authSession?.accessToken)
window.launcherTailscale?.logout().catch(() => undefined)
window.localStorage.removeItem(AUTH_SESSION_KEY) window.localStorage.removeItem(AUTH_SESSION_KEY)
window.sessionStorage.removeItem(AUTH_SESSION_KEY) window.sessionStorage.removeItem(AUTH_SESSION_KEY)
setAuthSession(null) setAuthSession(null)
}, [authSession?.accessToken, requestLogout]) }, [authSession?.accessToken, requestLogout])
const handleSessionExpired = useCallback((): void => {
if (sessionExpiredRef.current) return
sessionExpiredRef.current = true
blockLauncherSessionRefresh()
window.launcherSession?.endUserSession().catch(() => undefined)
setSessionExpiredOpen(true)
}, [])
const confirmSessionExpired = useCallback((): void => { useEffect(() => {
window.localStorage.removeItem(AUTH_SESSION_KEY) if (!authSession?.accessToken) return
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
setSessionExpiredOpen(false)
setAuthSession(null)
}, [])
const handleLogin = useCallback((session: AuthSession): void => { const accessToken = authSession.accessToken
sessionExpiredRef.current = false const handleSessionClose = (): void => {
resetLauncherSessionRefresh() requestLogout(accessToken)
setSessionExpiredOpen(false) }
setAuthSession(session)
}, []) window.addEventListener('pagehide', handleSessionClose)
window.addEventListener('beforeunload', handleSessionClose)
return () => {
window.removeEventListener('pagehide', handleSessionClose)
window.removeEventListener('beforeunload', handleSessionClose)
}
}, [authSession?.accessToken, requestLogout])
useEffect(() => { useEffect(() => {
if (!authSession?.accessToken) return if (!authSession?.accessToken) return
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000) const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
let timer: number | null = null const timer = window.setTimeout(() => {
const refresh = (): void => { fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
refreshLauncherSession() method: 'POST',
credentials: 'include',
})
.then((response) => response.ok ? response.json() as Promise<AuthSession> : null)
.then((session) => { .then((session) => {
if (session?.accessToken) { if (session?.accessToken) {
handleSessionUpdate(session) handleSessionUpdate(session)
} else { } else {
handleSessionExpired() handleLogout()
} }
}) })
.catch((error: unknown) => { .catch(() => {
console.warn('[launcher] session refresh failed; retrying in 30 seconds', error) handleLogout()
timer = window.setTimeout(refresh, 30_000)
}) })
} }, refreshDelayMs)
timer = window.setTimeout(refresh, refreshDelayMs) return () => window.clearTimeout(timer)
}, [authSession, handleLogout, handleSessionUpdate])
return () => { return authSession
if (timer !== null) window.clearTimeout(timer) ? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} />
} : <Login onLogin={setAuthSession} />
}, [authSession, handleSessionExpired, handleSessionUpdate])
return (
<>
{authSession
&& !sessionExpiredOpen
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} onSessionExpired={handleSessionExpired} />
: <Login onLogin={handleLogin} />}
{sessionExpiredOpen && createPortal(
<div className="fixed inset-0 z-[20000] flex items-center justify-center bg-[rgba(2,6,18,0.5)] px-4 backdrop-blur-[6px]">
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-[rgba(255,255,255,0.18)] bg-[rgba(22,27,39,0.86)] shadow-[0_28px_90px_rgba(0,0,0,0.58),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-2xl">
<div className="border-b border-[rgba(255,255,255,0.1)] px-5 py-4">
<h2 className="text-[16px] font-bold text-[var(--text-primary)]">로그인이 필요합니다</h2>
</div>
<div className="px-5 py-5 text-[13px] leading-6 text-[var(--text-muted)]">
로그인 세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.
</div>
<div className="flex justify-end border-t border-[rgba(255,255,255,0.1)] bg-[rgba(255,255,255,0.025)] px-5 py-3">
<button
type="button"
onClick={confirmSessionExpired}
className="rounded-md bg-[var(--accent)] px-4 py-2 text-[13px] font-semibold text-white transition-colors hover:bg-[#6aa0ff]"
>
확인
</button>
</div>
</section>
</div>,
document.body,
)}
</>
)
} }
export default App export default App
+1 -14
View File
@@ -355,7 +355,6 @@ body,
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 7px;
padding: 7px 16px; padding: 7px 16px;
min-width: 78px; min-width: 78px;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
@@ -438,7 +437,7 @@ body,
padding: 9px 12px; padding: 9px 12px;
background: transparent; background: transparent;
border: 0; border: 0;
color: var(--text-secondary); color: #fca5a5;
font-family: 'NanumGothic', sans-serif; font-family: 'NanumGothic', sans-serif;
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
@@ -447,14 +446,6 @@ body,
} }
.launch-action-dropdown button:hover:not(:disabled) { .launch-action-dropdown button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.07);
}
.launch-action-dropdown .launch-action-danger {
color: #fca5a5;
}
.launch-action-dropdown .launch-action-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.12); background: rgba(239, 68, 68, 0.12);
} }
@@ -728,10 +719,6 @@ body,
line-height: 18px; line-height: 18px;
} }
.login-version-wrap .launcher-update-text {
transform: translateY(-1px);
}
.app-version-wrap { .app-version-wrap {
position: absolute; position: absolute;
right: 24px; right: 24px;
-37
View File
@@ -1,37 +0,0 @@
import type { AuthSession } from '../pages/LoginForm'
const launcherApiBaseUrl = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
let pendingRefresh: Promise<AuthSession | null> | null = null
let refreshBlocked = false
export function blockLauncherSessionRefresh(): void {
refreshBlocked = true
}
export function resetLauncherSessionRefresh(): void {
refreshBlocked = false
}
export function refreshLauncherSession(): Promise<AuthSession | null> {
if (refreshBlocked) return Promise.resolve(null)
if (pendingRefresh) return pendingRefresh
pendingRefresh = fetch(`${launcherApiBaseUrl}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
.then((response) => {
if (response.ok) return response.json() as Promise<AuthSession>
if (response.status === 401 || response.status === 403) {
refreshBlocked = true
return null
}
throw new Error(`세션 갱신 요청에 실패했습니다. (${response.status})`)
})
.finally(() => {
pendingRefresh = null
})
return pendingRefresh
}
+15 -287
View File
@@ -21,17 +21,12 @@ import {
MonitorCog, MonitorCog,
Palette, Palette,
Info, Info,
Network, Network
Copy,
Check
} from 'lucide-react' } from 'lucide-react'
import type { LauncherApp } from '../config/apps' import type { LauncherApp } from '../config/apps'
const ICON_SIZE = 14 const ICON_SIZE = 14
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '') const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const PLATFORM_AGENT_INSTALL_URL = import.meta.env.VITE_PLATFORM_AGENT_INSTALL_URL
|| 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-files/public/platform-agent/install.sh'
const PLATFORM_AGENT_INSTALL_COMMAND = `curl -fsSL ${PLATFORM_AGENT_INSTALL_URL} | bash`
const APP_ICONS: Record<string, React.ReactNode> = { const APP_ICONS: Record<string, React.ReactNode> = {
code: <Code2 size={ICON_SIZE} />, code: <Code2 size={ICON_SIZE} />,
@@ -77,7 +72,6 @@ interface SidebarProps {
launcherUpdateMessage: string launcherUpdateMessage: string
onLauncherUpdate: () => void onLauncherUpdate: () => void
authAccessToken: string authAccessToken: string
authLoginMethod?: 'GOOGLE' | 'PASSWORD'
tailscaleIssue?: { tailscaleIssue?: {
state?: 'checking' | 'warning' state?: 'checking' | 'warning'
title: string title: string
@@ -87,7 +81,7 @@ interface SidebarProps {
} }
type UserModal = 'profile' | 'settings' | 'platforms' | null type UserModal = 'profile' | 'settings' | 'platforms' | null
type SettingsTabKey = 'general' | 'remote' | 'theme' | 'info' type SettingsTabKey = 'general' | 'theme' | 'info'
type VpnPlatformPeer = { type VpnPlatformPeer = {
id: string id: string
hostName: string hostName: string
@@ -111,7 +105,7 @@ function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof
) )
} }
function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastLoginAt, primaryAffiliation, currentLicense, activeApp, onAppChange, onLogout, collapsed, onToggleCollapse, launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, authAccessToken, authLoginMethod, tailscaleIssue, onTailscaleRetry }: SidebarProps): React.JSX.Element { function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastLoginAt, primaryAffiliation, currentLicense, activeApp, onAppChange, onLogout, collapsed, onToggleCollapse, launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, authAccessToken, tailscaleIssue, onTailscaleRetry }: SidebarProps): React.JSX.Element {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [modal, setModal] = useState<UserModal>(null) const [modal, setModal] = useState<UserModal>(null)
const [avatarVersion, setAvatarVersion] = useState(() => Date.now()) const [avatarVersion, setAvatarVersion] = useState(() => Date.now())
@@ -402,7 +396,6 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
primaryAffiliation={primaryAffiliation} primaryAffiliation={primaryAffiliation}
currentLicense={currentLicense} currentLicense={currentLicense}
accessToken={authAccessToken} accessToken={authAccessToken}
loginMethod={authLoginMethod}
avatarObjectUrl={avatarObjectUrl} avatarObjectUrl={avatarObjectUrl}
avatarMissing={avatarMissing} avatarMissing={avatarMissing}
onAvatarMissingChange={setAvatarMissing} onAvatarMissingChange={setAvatarMissing}
@@ -415,7 +408,7 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
)} )}
{modal === 'platforms' && ( {modal === 'platforms' && (
<PlatformManagementModal accessToken={authAccessToken} onClose={() => setModal(null)} /> <PlatformManagementModal onClose={() => setModal(null)} />
)} )}
{modal === 'settings' && ( {modal === 'settings' && (
@@ -424,7 +417,6 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
launcherUpdating={launcherUpdating} launcherUpdating={launcherUpdating}
launcherUpdateMessage={launcherUpdateMessage} launcherUpdateMessage={launcherUpdateMessage}
onLauncherUpdate={onLauncherUpdate} onLauncherUpdate={onLauncherUpdate}
onTailscaleRetry={onTailscaleRetry}
onClose={() => setModal(null)} onClose={() => setModal(null)}
/> />
)} )}
@@ -432,20 +424,13 @@ function Sidebar({ apps, userName, userEmail, userRole, userCreatedAt, userLastL
) )
} }
function PlatformManagementModal({ accessToken, onClose }: { accessToken: string; onClose: () => void }): React.JSX.Element { function PlatformManagementModal({ onClose }: { onClose: () => void }): React.JSX.Element {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [installing, setInstalling] = useState(false) const [installing, setInstalling] = useState(false)
const [tailscaleMissing, setTailscaleMissing] = useState(false) const [tailscaleMissing, setTailscaleMissing] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [platforms, setPlatforms] = useState<VpnPlatformPeer[]>([]) const [platforms, setPlatforms] = useState<VpnPlatformPeer[]>([])
const [selectedId, setSelectedId] = useState('') const [selectedId, setSelectedId] = useState('')
const [registrationCode, setRegistrationCode] = useState('')
const [registrationExpiresAt, setRegistrationExpiresAt] = useState('')
const [registrationNow, setRegistrationNow] = useState(() => Date.now())
const [registrationLoading, setRegistrationLoading] = useState(false)
const [authorizationError, setAuthorizationError] = useState('')
const [registrationGuideOpen, setRegistrationGuideOpen] = useState(false)
const [registrationGuideCopied, setRegistrationGuideCopied] = useState(false)
const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => { const loadPlatforms = useCallback(async (cancelledRef?: { current: boolean }): Promise<void> => {
setLoading(true) setLoading(true)
@@ -498,39 +483,6 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
} }
}, [loadPlatforms]) }, [loadPlatforms])
useEffect(() => {
let cancelled = false
const loadRegistrationCode = async (): Promise<void> => {
try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision/device-authorizations/current`, {
headers: { Authorization: `Bearer ${accessToken}` },
credentials: 'include',
})
if (!response.ok) return
const payload = await response.json().catch(() => null) as { code?: string; expiresAt?: string } | null
if (!cancelled && payload?.code) {
setRegistrationCode(payload.code)
setRegistrationExpiresAt(payload.expiresAt || '')
}
} catch {
// A registration code is optional; leave the modal usable when lookup fails.
}
}
void loadRegistrationCode()
return () => {
cancelled = true
}
}, [accessToken])
useEffect(() => {
if (!registrationExpiresAt) return
setRegistrationNow(Date.now())
const timer = window.setInterval(() => setRegistrationNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [registrationExpiresAt])
const installTailscale = async (): Promise<void> => { const installTailscale = async (): Promise<void> => {
if (!window.launcherTailscale?.install) return if (!window.launcherTailscale?.install) return
setInstalling(true) setInstalling(true)
@@ -549,65 +501,6 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
const selectedPlatform = platforms.find((platform) => platform.id === selectedId) ?? platforms[0] const selectedPlatform = platforms.find((platform) => platform.id === selectedId) ?? platforms[0]
const createAgentRegistrationCode = async (): Promise<void> => {
if (registrationLoading) return
setRegistrationLoading(true)
setAuthorizationError('')
try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision/device-authorizations`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
credentials: 'include',
})
const payload = await response.json().catch(() => null) as { code?: string; expiresAt?: string; message?: string } | null
if (!response.ok) {
throw new Error(payload?.message || '등록 키를 만들지 못했습니다.')
}
if (!payload?.code) throw new Error('등록 키 응답이 올바르지 않습니다.')
setRegistrationCode(payload.code)
setRegistrationExpiresAt(payload.expiresAt || '')
} catch (createError) {
setAuthorizationError(createError instanceof Error ? createError.message : '등록 키를 만들지 못했습니다.')
} finally {
setRegistrationLoading(false)
}
}
const cancelAgentRegistrationCode = async (): Promise<void> => {
if (!registrationCode || registrationLoading) return
setRegistrationLoading(true)
setAuthorizationError('')
try {
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision/device-authorizations/current/cancel`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
credentials: 'include',
})
if (!response.ok) throw new Error('등록 키를 취소하지 못했습니다.')
setRegistrationCode('')
setRegistrationExpiresAt('')
} catch (cancelError) {
setAuthorizationError(cancelError instanceof Error ? cancelError.message : '등록 키를 취소하지 못했습니다.')
} finally {
setRegistrationLoading(false)
}
}
const copyRegistrationGuide = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(PLATFORM_AGENT_INSTALL_COMMAND)
setRegistrationGuideCopied(true)
window.setTimeout(() => setRegistrationGuideCopied(false), 1600)
} catch {
setAuthorizationError('설치 명령을 복사하지 못했습니다.')
}
}
return ( return (
<ModalShell <ModalShell
title="플랫폼 관리" title="플랫폼 관리"
@@ -656,16 +549,16 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
key={platform.id} key={platform.id}
type="button" type="button"
onClick={() => setSelectedId(platform.id)} onClick={() => setSelectedId(platform.id)}
className={`flex w-full min-w-0 overflow-hidden items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors cursor-pointer ${ className={`flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors cursor-pointer ${
active active
? 'border-[rgba(103,232,249,0.42)] bg-[rgba(103,232,249,0.12)]' ? 'border-[rgba(103,232,249,0.42)] bg-[rgba(103,232,249,0.12)]'
: 'border-transparent hover:border-white/10 hover:bg-white/[0.05]' : 'border-transparent hover:border-white/10 hover:bg-white/[0.05]'
}`} }`}
> >
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-[#35d27f]' : 'bg-[#ef4444]'}`} /> <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${connected ? 'bg-[#35d27f]' : 'bg-[#ef4444]'}`} />
<span className="min-w-0 flex-1 overflow-hidden"> <span className="min-w-0 flex-1">
<span title={platform.hostName} className="block truncate text-[12px] font-bold text-white/86">{platform.hostName}</span> <span className="block truncate text-[12px] font-bold text-white/86">{platform.hostName}</span>
<span title={platform.dnsName || platform.loginName || '플랫폼 장비'} className="mt-0.5 block truncate text-[11px] font-medium text-white/42">{platform.dnsName || platform.loginName || '플랫폼 장비'}</span> <span className="mt-0.5 block truncate text-[11px] font-medium text-white/42">{platform.dnsName || platform.loginName || '플랫폼 장비'}</span>
</span> </span>
</button> </button>
) )
@@ -676,68 +569,6 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
</aside> </aside>
<section className="min-h-0 overflow-y-auto px-6 py-5"> <section className="min-h-0 overflow-y-auto px-6 py-5">
<section className="mb-5 rounded-md border border-[rgba(129,199,255,0.18)] bg-[rgba(77,151,255,0.06)] p-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-[13px] font-bold text-white/86">플랫폼 등록</p>
<p className="mt-1 text-[11px] font-medium leading-5 text-white/48">에이전트 설치 화면에서 현재 계정 이메일과 등록 키를 입력하세요.</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<button
type="button"
onClick={() => void createAgentRegistrationCode()}
disabled={registrationLoading}
className="h-7 w-[72px] cursor-pointer rounded-md bg-[#5588ff] text-[11px] font-bold text-white transition-colors hover:bg-[#4778ea] disabled:cursor-default disabled:opacity-50"
>
키 발급
</button>
<button
type="button"
aria-expanded={registrationGuideOpen}
onClick={() => setRegistrationGuideOpen((open) => !open)}
className="h-7 w-[72px] cursor-pointer rounded-md border border-white/14 text-[11px] font-bold text-white/65 transition-colors hover:border-white/24 hover:bg-white/[0.06] hover:text-white"
>
등록 방법
</button>
</div>
</div>
{registrationGuideOpen && (
<div className="mt-3 rounded-md border border-white/10 bg-black/20 px-3 py-2.5">
<p className="text-[11px] font-medium leading-5 text-white/48">플랫폼 장비의 터미널에서 아래 명령을 실행하세요.</p>
<div className="mt-2 flex items-start gap-2">
<code className="min-w-0 flex-1 select-all break-all font-mono text-[10px] font-medium leading-5 text-white/72">{PLATFORM_AGENT_INSTALL_COMMAND}</code>
<button
type="button"
title="설치 명령 복사"
aria-label="설치 명령 복사"
onClick={() => void copyRegistrationGuide()}
className="grid size-7 shrink-0 cursor-pointer place-items-center rounded-md border border-white/14 text-white/65 transition-colors hover:border-white/24 hover:bg-white/[0.06] hover:text-white"
>
{registrationGuideCopied ? <Check size={13} className="text-[#85efac]" /> : <Copy size={13} />}
</button>
</div>
</div>
)}
{registrationCode && (
<div className="mt-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="rounded-md border border-[#78a7ff]/35 bg-[#5588ff]/10 px-3 py-2 font-mono text-[16px] font-bold tracking-[0.18em] text-[#c5d7ff]">
{registrationCode}
</span>
{registrationExpiresAt && <p className="shrink-0 text-[11px] font-medium leading-5 text-white/42">{formatRemainingTime(registrationExpiresAt, registrationNow)}</p>}
</div>
<button
type="button"
onClick={() => void cancelAgentRegistrationCode()}
disabled={registrationLoading}
className="h-8 cursor-pointer rounded-md border border-white/14 px-3 text-[11px] font-bold text-white/65 transition-colors hover:border-white/24 hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-50"
>
취소
</button>
</div>
)}
{authorizationError && <p className="mt-3 text-[11px] font-medium leading-5 text-[#fca5a5]">{authorizationError}</p>}
</section>
{tailscaleMissing ? ( {tailscaleMissing ? (
<div className="grid h-full place-items-center px-8 text-center"> <div className="grid h-full place-items-center px-8 text-center">
<div> <div>
@@ -754,7 +585,7 @@ function PlatformManagementModal({ accessToken, onClose }: { accessToken: string
</div> </div>
</div> </div>
) : !selectedPlatform ? ( ) : !selectedPlatform ? (
<div className="grid place-items-center py-8 text-[13px] font-semibold text-white/45">플랫폼을 선택하세요.</div> <div className="grid h-full place-items-center text-[13px] font-semibold text-white/45">플랫폼을 선택하세요.</div>
) : ( ) : (
<> <>
<div className="mb-5 flex items-start justify-between gap-4"> <div className="mb-5 flex items-start justify-between gap-4">
@@ -830,7 +661,7 @@ function ModalShell({ title, icon, children, footer, panelClassName = 'max-h-[82
) )
} }
function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, primaryAffiliation, currentLicense, accessToken, loginMethod, avatarObjectUrl, avatarMissing, onAvatarMissingChange, onAvatarUpdated, onClose }: { function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, primaryAffiliation, currentLicense, accessToken, avatarObjectUrl, avatarMissing, onAvatarMissingChange, onAvatarUpdated, onClose }: {
initial: string initial: string
userName: string userName: string
userEmail: string userEmail: string
@@ -840,7 +671,6 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
primaryAffiliation?: SidebarProps['primaryAffiliation'] primaryAffiliation?: SidebarProps['primaryAffiliation']
currentLicense?: SidebarProps['currentLicense'] currentLicense?: SidebarProps['currentLicense']
accessToken: string accessToken: string
loginMethod?: 'GOOGLE' | 'PASSWORD'
avatarObjectUrl: string | null avatarObjectUrl: string | null
avatarMissing: boolean avatarMissing: boolean
onAvatarMissingChange: (missing: boolean) => void onAvatarMissingChange: (missing: boolean) => void
@@ -868,7 +698,6 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
const licensePeriod = currentLicense const licensePeriod = currentLicense
? `${formatDate(currentLicense.startsAt)} ~ ${currentLicense.expiresAt ? formatDate(currentLicense.expiresAt) : '무기한'}` ? `${formatDate(currentLicense.startsAt)} ~ ${currentLicense.expiresAt ? formatDate(currentLicense.expiresAt) : '무기한'}`
: '-' : '-'
const isGoogleLogin = loginMethod === 'GOOGLE'
const resetPasswordForm = (): void => { const resetPasswordForm = (): void => {
setCurrentPassword('') setCurrentPassword('')
@@ -1087,10 +916,8 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
<SecurityAction <SecurityAction
icon={<KeyRound size={15} />} icon={<KeyRound size={15} />}
title="비밀번호 변경" title="비밀번호 변경"
description={isGoogleLogin ? 'Google 로그인 계정은 비밀번호를 변경할 수 없습니다.' : '현재 비밀번호 확인 후 새 비밀번호 설정.'} description="현재 비밀번호 확인 후 새 비밀번호 설정."
disabled={isGoogleLogin}
onClick={() => { onClick={() => {
if (isGoogleLogin) return
resetPasswordForm() resetPasswordForm()
setPasswordMode(true) setPasswordMode(true)
}} }}
@@ -1145,61 +972,19 @@ function ProfileModal({ initial, userName, userEmail, createdAt, lastLoginAt, pr
) )
} }
function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, onTailscaleRetry, onClose }: { function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpdateMessage, onLauncherUpdate, onClose }: {
launcherUpdateAvailable: boolean launcherUpdateAvailable: boolean
launcherUpdating: boolean launcherUpdating: boolean
launcherUpdateMessage: string launcherUpdateMessage: string
onLauncherUpdate: () => void onLauncherUpdate: () => void
onTailscaleRetry?: () => void
onClose: () => void onClose: () => void
}): React.JSX.Element { }): React.JSX.Element {
const [activeTab, setActiveTab] = useState<SettingsTabKey>('general') const [activeTab, setActiveTab] = useState<SettingsTabKey>('general')
const [clearingCache, setClearingCache] = useState(false) const [clearingCache, setClearingCache] = useState(false)
const [cacheAvailable, setCacheAvailable] = useState(true) const [cacheAvailable, setCacheAvailable] = useState(true)
const [cacheMessage, setCacheMessage] = useState('') const [cacheMessage, setCacheMessage] = useState('')
const [tailscaleStatus, setTailscaleStatus] = useState<Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null>(null)
const [tailscaleLoading, setTailscaleLoading] = useState(false)
const [tailscaleRestarting, setTailscaleRestarting] = useState(false)
const [tailscaleMessage, setTailscaleMessage] = useState('')
const version = import.meta.env.VITE_APP_VERSION const version = import.meta.env.VITE_APP_VERSION
const loadTailscaleStatus = useCallback(async (): Promise<void> => {
if (!window.launcherTailscale) {
setTailscaleMessage('데스크톱 런처에서만 원격 연결 상태를 확인할 수 있습니다.')
return
}
setTailscaleLoading(true)
try {
const status = await window.launcherTailscale.status()
setTailscaleStatus(status)
setTailscaleMessage(status.error ?? '')
} catch {
setTailscaleMessage('원격 연결 상태를 확인하지 못했습니다.')
} finally {
setTailscaleLoading(false)
}
}, [])
useEffect(() => {
if (activeTab === 'remote') void loadTailscaleStatus()
}, [activeTab, loadTailscaleStatus])
const restartTailscaleService = async (): Promise<void> => {
if (!window.launcherTailscale?.restartService || tailscaleRestarting) return
setTailscaleRestarting(true)
setTailscaleMessage('')
try {
const result = await window.launcherTailscale.restartService()
setTailscaleMessage(result.message)
if (result.ok) await loadTailscaleStatus()
} catch {
setTailscaleMessage('원격 연결 서비스 재시작에 실패했습니다.')
} finally {
setTailscaleRestarting(false)
}
}
const handleClearCache = async (): Promise<void> => { const handleClearCache = async (): Promise<void> => {
if (!cacheAvailable) return if (!cacheAvailable) return
@@ -1241,7 +1026,6 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda
<div className="grid h-full grid-cols-[138px_1fr]"> <div className="grid h-full grid-cols-[138px_1fr]">
<aside className="border-r border-[rgba(255,255,255,0.08)] p-3"> <aside className="border-r border-[rgba(255,255,255,0.08)] p-3">
<SettingsTab icon={<MonitorCog size={15} />} label="일반" active={activeTab === 'general'} onClick={() => setActiveTab('general')} /> <SettingsTab icon={<MonitorCog size={15} />} label="일반" active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
<SettingsTab icon={<Network size={15} />} label="원격 연결" active={activeTab === 'remote'} onClick={() => setActiveTab('remote')} />
<SettingsTab icon={<Palette size={15} />} label="테마" active={activeTab === 'theme'} onClick={() => setActiveTab('theme')} /> <SettingsTab icon={<Palette size={15} />} label="테마" active={activeTab === 'theme'} onClick={() => setActiveTab('theme')} />
<SettingsTab icon={<Info size={15} />} label="정보" active={activeTab === 'info'} onClick={() => setActiveTab('info')} /> <SettingsTab icon={<Info size={15} />} label="정보" active={activeTab === 'info'} onClick={() => setActiveTab('info')} />
</aside> </aside>
@@ -1307,55 +1091,6 @@ function SettingsModal({ launcherUpdateAvailable, launcherUpdating, launcherUpda
</> </>
)} )}
{activeTab === 'remote' && (
<>
<h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">원격 연결</h3>
<SettingBlock title="연결 상태" description="현재 연결 상태">
<div className="flex min-w-[150px] flex-col items-end gap-1.5">
<span className={`text-[11px] font-bold ${tailscaleStatus?.connected ? 'text-[#58d68d]' : 'text-[#f3b44f]'}`}>
{tailscaleLoading ? '확인 중' : tailscaleStatus?.connected ? '연결됨' : '연결 필요'}
</span>
{tailscaleStatus?.ip && <span className="text-[11px] text-[var(--text-muted)]">{tailscaleStatus.ip}</span>}
</div>
</SettingBlock>
<SettingBlock title="계정" description="로그인된 계정">
<span className="max-w-[220px] truncate text-right text-[12px] font-medium text-[var(--text-secondary)]">
{tailscaleStatus?.loginName || tailscaleStatus?.userName || '-'}
</span>
</SettingBlock>
<SettingBlock title="연결 다시 시도" description="연결 정보를 다시 확인">
<button
type="button"
onClick={() => {
onTailscaleRetry?.()
void loadTailscaleStatus()
}}
disabled={!onTailscaleRetry || tailscaleLoading}
className={`rounded-md border border-[rgba(255,255,255,0.14)] px-3 py-2 text-[12px] font-semibold transition-colors ${
onTailscaleRetry && !tailscaleLoading ? 'cursor-pointer text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white' : 'cursor-not-allowed text-white/35 opacity-60'
}`}
>
다시 연결
</button>
</SettingBlock>
<SettingBlock title="서비스 재시작" description="tailscaled 서비스 재시작" last>
<div className="flex flex-col items-end gap-1.5">
<button
type="button"
onClick={() => void restartTailscaleService()}
disabled={!window.launcherTailscale?.restartService || tailscaleRestarting}
className={`rounded-md border border-[rgba(255,255,255,0.14)] px-3 py-2 text-[12px] font-semibold transition-colors ${
window.launcherTailscale?.restartService && !tailscaleRestarting ? 'cursor-pointer text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white' : 'cursor-not-allowed text-white/35 opacity-60'
}`}
>
{tailscaleRestarting ? '재시작 중' : '서비스 재시작'}
</button>
{tailscaleMessage && <span className="max-w-[230px] text-right text-[11px] leading-4 text-[var(--text-muted)]">{tailscaleMessage}</span>}
</div>
</SettingBlock>
</>
)}
{activeTab === 'info' && ( {activeTab === 'info' && (
<> <>
<h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">정보</h3> <h3 className="mb-5 text-[15px] font-bold text-[var(--text-primary)]">정보</h3>
@@ -1381,9 +1116,9 @@ function ProfileRow({ label, value }: { label: string; value: string }): React.J
) )
} }
function SecurityAction({ icon, title, description, status, disabled = false, onClick }: { icon: React.ReactNode; title: string; description: string; status?: string; disabled?: boolean; onClick?: () => void }): React.JSX.Element { function SecurityAction({ icon, title, description, status, onClick }: { icon: React.ReactNode; title: string; description: string; status?: string; onClick?: () => void }): React.JSX.Element {
return ( return (
<button type="button" onClick={onClick} disabled={disabled} className={`flex w-full items-center gap-3 border-t border-[rgba(255,255,255,0.07)] py-3 text-left ${disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer hover:bg-white/[0.03]'}`}> <button type="button" onClick={onClick} className="flex w-full items-center gap-3 border-t border-[rgba(255,255,255,0.07)] py-3 text-left cursor-pointer hover:bg-white/[0.03]">
<span className="text-white/55">{icon}</span> <span className="text-white/55">{icon}</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-[13px] font-semibold text-[var(--text-primary)]">{title}</span> <span className="block text-[13px] font-semibold text-[var(--text-primary)]">{title}</span>
@@ -1462,13 +1197,6 @@ function formatOptionalDateTime(value?: string): string {
return formatDateTime(value) return formatDateTime(value)
} }
function formatRemainingTime(expiresAt: string, now: number): string {
const remainingSeconds = Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000))
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
return `${minutes}분 ${String(seconds).padStart(2, '0')}초`
}
async function resizeImageToWebp(file: File, size: number): Promise<Blob> { async function resizeImageToWebp(file: File, size: number): Promise<Blob> {
const dataUrl = await new Promise<string>((resolve, reject) => { const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
-11
View File
@@ -40,10 +40,6 @@ declare global {
ok: boolean ok: boolean
message: string message: string
}> }>
ensureOperator: () => Promise<{
ok: boolean
message: string
}>
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{ login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
ok: boolean ok: boolean
message: string message: string
@@ -52,10 +48,6 @@ declare global {
ok: boolean ok: boolean
message: string message: string
}> }>
restartService: () => Promise<{
ok: boolean
message: string
}>
} }
launcherSshKey?: { launcherSshKey?: {
ensure: () => Promise<{ ensure: () => Promise<{
@@ -70,9 +62,6 @@ declare global {
callback: (payload: { loginToken?: string; error?: string }) => void callback: (payload: { loginToken?: string; error?: string }) => void
) => () => void ) => () => void
} }
launcherSession?: {
endUserSession: () => Promise<{ ok: boolean }>
}
} }
} }
+36 -118
View File
@@ -5,7 +5,6 @@ import Tooltip from '../components/Tooltip'
import AppView from './AppView' import AppView from './AppView'
import type { LauncherApp } from '../config/apps' import type { LauncherApp } from '../config/apps'
import type { AuthSession } from './LoginForm' import type { AuthSession } from './LoginForm'
import { refreshLauncherSession } from '../auth/sessionRefresh'
const version = import.meta.env.VITE_APP_VERSION const version = import.meta.env.VITE_APP_VERSION
@@ -13,18 +12,11 @@ interface DashboardProps {
authSession: AuthSession authSession: AuthSession
onSessionUpdate: (session: AuthSession) => void onSessionUpdate: (session: AuthSession) => void
onLogout: () => void onLogout: () => void
onSessionExpired: () => void
} }
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '') const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const MANUAL_BASE_URL = (import.meta.env.VITE_MANUAL_BASE_URL || 'https://portal.autonomystudio.net/manual').replace(/\/+$/, '')
const LMS_WEB_BASE_URL = `${(
import.meta.env.VITE_LMS_WEB_BASE_URL
|| (import.meta.env.DEV ? 'http://localhost:5177' : 'https://workspace.autonomystudio.net/lms-web/')
).replace(/\/+$/, '')}/`
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures' const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp' const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
const TAILSCALE_DEVICE_ID_STORAGE_KEY = 'atnmy.launcher.tailscale-device-id'
const IS_DEV = import.meta.env.DEV const IS_DEV = import.meta.env.DEV
const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000 const TAILSCALE_BACKGROUND_TIMEOUT_MS = 20_000
@@ -103,21 +95,6 @@ function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount:
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount) return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
} }
function getTailscaleDeviceId(): string {
try {
const stored = window.localStorage.getItem(TAILSCALE_DEVICE_ID_STORAGE_KEY)?.trim()
if (stored && /^[a-z0-9-]{6,24}$/i.test(stored)) return stored
const next = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/g, '').slice(0, 10)
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
window.localStorage.setItem(TAILSCALE_DEVICE_ID_STORAGE_KEY, next)
return next
} catch {
return 'launcher-client'
}
}
function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean { function isTailscaleMissingStatus(status: Awaited<ReturnType<NonNullable<typeof window.launcherTailscale>['status']>> | null): boolean {
return Boolean( return Boolean(
status?.backendState === 'Unavailable' && status?.backendState === 'Unavailable' &&
@@ -147,7 +124,7 @@ function extractProvisionErrorMessage(payload: unknown): string {
} }
} }
function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }: DashboardProps): React.JSX.Element { function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
const [apps, setApps] = useState<LauncherApp[]>([]) const [apps, setApps] = useState<LauncherApp[]>([])
const [activeApp, setActiveApp] = useState('') const [activeApp, setActiveApp] = useState('')
const [appNotices, setAppNotices] = useState<AppNotice[]>([]) const [appNotices, setAppNotices] = useState<AppNotice[]>([])
@@ -178,11 +155,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
const noticeRef = useRef<HTMLDivElement>(null) const noticeRef = useRef<HTMLDivElement>(null)
const appActionMenuRef = useRef<HTMLDivElement>(null) const appActionMenuRef = useRef<HTMLDivElement>(null)
const initialAppSelectionDoneRef = useRef(false) const initialAppSelectionDoneRef = useRef(false)
const authSessionRef = useRef(authSession) const tailscaleProvisionUserRef = useRef('')
useEffect(() => {
authSessionRef.current = authSession
}, [authSession])
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
const hasNotices = appNotices.length > 0 const hasNotices = appNotices.length > 0
@@ -211,15 +184,16 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
: null : null
const activeAppDisplayName = appStatus?.displayName || appConfig?.displayName || activeApp const activeAppDisplayName = appStatus?.displayName || appConfig?.displayName || activeApp
const refreshSession = useCallback(async (): Promise<AuthSession | null> => { const refreshSession = useCallback(async (): Promise<AuthSession | null> => {
try { const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
const session = await refreshLauncherSession() method: 'POST',
if (!session) return null credentials: 'include',
})
if (!response.ok) return null
const session = await response.json() as AuthSession
onSessionUpdate(session) onSessionUpdate(session)
return session return session
} catch (error) {
console.warn('[launcher] session refresh temporarily failed', error)
return authSessionRef.current
}
}, [onSessionUpdate]) }, [onSessionUpdate])
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => { const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
@@ -237,21 +211,21 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
const refreshed = await refreshSession() const refreshed = await refreshSession()
if (!refreshed) { if (!refreshed) {
onSessionExpired() onLogout()
return response return response
} }
response = await request(refreshed.accessToken) response = await request(refreshed.accessToken)
return response return response
}, [authSession.accessToken, onSessionExpired, refreshSession]) }, [authSession.accessToken, onLogout, refreshSession])
const fetchWithAuthRefreshRef = useRef(fetchWithAuthRefresh)
fetchWithAuthRefreshRef.current = fetchWithAuthRefresh
useEffect(() => { useEffect(() => {
const launcherTailscale = window.launcherTailscale const launcherTailscale = window.launcherTailscale
if (!launcherTailscale) return if (!launcherTailscale) return
const userEmail = authSession.user.email.trim().toLowerCase() const userEmail = authSession.user.email.trim().toLowerCase()
if (!userEmail) return const provisionKey = `${userEmail}:${tailscaleRetryNonce}`
if (!userEmail || tailscaleProvisionUserRef.current === provisionKey) return
tailscaleProvisionUserRef.current = provisionKey
let cancelled = false let cancelled = false
const connectTailscaleInBackground = async (): Promise<void> => { const connectTailscaleInBackground = async (): Promise<void> => {
@@ -273,9 +247,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
return return
} }
const hasAccountIdentity = Boolean(
status?.loginName?.trim() || status?.userName?.trim(),
)
const isSameAccount = Boolean( const isSameAccount = Boolean(
status && status &&
( (
@@ -283,7 +254,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
isSameTailscaleAccount(status.userName, userEmail) isSameTailscaleAccount(status.userName, userEmail)
), ),
) )
if (status?.connected && (!hasAccountIdentity || isSameAccount)) { if (status?.connected && isSameAccount) {
setTailscaleIssue(null) setTailscaleIssue(null)
return return
} }
@@ -291,13 +262,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
const sshKey = await window.launcherSshKey?.ensure().catch(() => null) const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
if (cancelled) return if (cancelled) return
const response = await fetchWithAuthRefreshRef.current(`${LAUNCHER_API_BASE_URL}/api/provision/session`, { const response = await fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/provision/session`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
hostname: window.navigator.userAgent.includes('Electron') hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
? `launcher-client-${getTailscaleDeviceId()}`
: `client-${getTailscaleDeviceId()}`,
usageType: 'launcher-client', usageType: 'launcher-client',
publicSshKey: sshKey?.publicKey, publicSshKey: sshKey?.publicKey,
}), }),
@@ -318,17 +287,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
return return
} }
const shouldResetSession = Boolean(
status &&
status.backendState !== 'Unavailable' &&
hasAccountIdentity &&
!isSameAccount,
)
const result = await launcherTailscale.login( const result = await launcherTailscale.login(
provision.tailscaleAuthKey, provision.tailscaleAuthKey,
provision.tailscaleLoginServer ?? '', provision.tailscaleLoginServer ?? '',
provision.tailscaleHostname, provision.tailscaleHostname,
shouldResetSession, Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
) )
if (!result.ok) { if (!result.ok) {
setTailscaleIssue({ setTailscaleIssue({
@@ -360,7 +323,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
} }
})(), TAILSCALE_BACKGROUND_TIMEOUT_MS, '원격 연결 확인 시간이 초과되었습니다.') })(), TAILSCALE_BACKGROUND_TIMEOUT_MS, '원격 연결 확인 시간이 초과되었습니다.')
} catch (error) { } catch (error) {
if (cancelled) return
setTailscaleIssue({ setTailscaleIssue({
state: 'warning', state: 'warning',
title: '원격 연결 확인 필요', title: '원격 연결 확인 필요',
@@ -375,7 +337,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
return () => { return () => {
cancelled = true cancelled = true
} }
}, [authSession.user.email, tailscaleRetryNonce]) }, [authSession.user.email, fetchWithAuthRefresh, tailscaleRetryNonce])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -392,7 +354,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
if (response.status === 401) { if (response.status === 401) {
const refreshed = await refreshSession() const refreshed = await refreshSession()
if (!refreshed) { if (!refreshed) {
if (!cancelled) onSessionExpired() if (!cancelled) onLogout()
return return
} }
@@ -411,10 +373,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
if (!cancelled && payload?.user) { if (!cancelled && payload?.user) {
const nextSession = { const nextSession = {
...session, ...session,
user: { user: payload.user,
...payload.user,
loginMethod: payload.user.loginMethod ?? authSession.user.loginMethod,
},
} }
if (JSON.stringify(nextSession.user) === JSON.stringify(authSession.user) && nextSession.accessToken === authSession.accessToken) { if (JSON.stringify(nextSession.user) === JSON.stringify(authSession.user) && nextSession.accessToken === authSession.accessToken) {
return return
@@ -430,7 +389,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
return () => { return () => {
cancelled = true cancelled = true
} }
}, [authSession, onSessionExpired, onSessionUpdate, refreshSession]) }, [authSession, onLogout, onSessionUpdate, refreshSession])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -758,12 +717,18 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
let response = await requestHandoff(authSession.accessToken) let response = await requestHandoff(authSession.accessToken)
if (response.status === 401) { if (response.status === 401) {
const nextSession = await refreshLauncherSession() const refreshResponse = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
if (nextSession) { method: 'POST',
credentials: 'include',
})
if (refreshResponse.ok) {
const nextSession = await refreshResponse.json() as AuthSession
onSessionUpdate(nextSession) onSessionUpdate(nextSession)
response = await requestHandoff(nextSession.accessToken) response = await requestHandoff(nextSession.accessToken)
} else { } else {
onSessionExpired() alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.')
onLogout()
return null return null
} }
} }
@@ -777,7 +742,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
const handoff = await response.json() as { handoffToken: string } const handoff = await response.json() as { handoffToken: string }
return handoff.handoffToken return handoff.handoffToken
}, [authSession.accessToken, onSessionExpired, onSessionUpdate]) }, [authSession.accessToken, onLogout, onSessionUpdate])
const handleLaunch = useCallback(async () => { const handleLaunch = useCallback(async () => {
if (!appConfig || !canLaunch || launchingApp) return if (!appConfig || !canLaunch || launchingApp) return
@@ -787,11 +752,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
let handoffCode: string | undefined let handoffCode: string | undefined
try { try {
await window.electron.ipcRenderer.invoke('prepare-app-runtime', {
appName: activeApp,
execPath: appConfig.execPath,
devAppPath: appConfig.devAppPath,
})
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp) const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
if (handoffAudienceApp) { if (handoffAudienceApp) {
const [handoffToken] = await Promise.all([ const [handoffToken] = await Promise.all([
@@ -817,39 +777,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
}) })
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp) window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
setRunningApps((prev) => new Set([...prev, activeApp])) setRunningApps((prev) => new Set([...prev, activeApp]))
} catch (error) {
alert(error instanceof Error ? error.message : '앱 실행 준비에 실패했습니다.')
} finally { } finally {
setLaunchingApp(null) setLaunchingApp(null)
} }
}, [activeApp, appConfig, canLaunch, createHandoffToken, launchingApp]) }, [activeApp, appConfig, canLaunch, createHandoffToken, launchingApp])
const handleWebLaunch = useCallback(async () => {
if (activeApp.trim().toLowerCase() !== 'lms' || launchingApp) return
setAppActionMenuOpen(false)
setLaunchingApp(activeApp)
try {
const handoffToken = await createHandoffToken('LMS')
if (!handoffToken) return
const launchUrl = new URL(LMS_WEB_BASE_URL)
launchUrl.hash = new URLSearchParams({
handoffToken,
audienceApp: 'LMS',
}).toString()
await window.electron.ipcRenderer.invoke('launch-web-app', {
appName: activeApp,
url: launchUrl.toString(),
})
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
} catch (error) {
alert(`웹 실행에 실패했습니다. (${error instanceof Error ? error.message : '알 수 없는 오류'})`)
} finally {
setLaunchingApp(null)
}
}, [activeApp, createHandoffToken, launchingApp])
const handleFocusApp = useCallback(() => { const handleFocusApp = useCallback(() => {
window.electron.ipcRenderer.send('focus-app-window', activeApp) window.electron.ipcRenderer.send('focus-app-window', activeApp)
}, [activeApp]) }, [activeApp])
@@ -908,7 +840,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
launcherUpdateMessage={launcherUpdateMessage} launcherUpdateMessage={launcherUpdateMessage}
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)} onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
authAccessToken={authSession.accessToken} authAccessToken={authSession.accessToken}
authLoginMethod={authSession.user.loginMethod}
tailscaleIssue={tailscaleIssue} tailscaleIssue={tailscaleIssue}
onTailscaleRetry={() => setTailscaleRetryNonce((value) => value + 1)} onTailscaleRetry={() => setTailscaleRetryNonce((value) => value + 1)}
/> />
@@ -1005,9 +936,6 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
) : canLaunch ? ( ) : canLaunch ? (
activeAppLaunching ? ( activeAppLaunching ? (
<button className="launch-pending-btn" disabled> <button className="launch-pending-btn" disabled>
<svg aria-hidden="true" className="spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
<path d="M12 2a10 10 0 1 0 10 10" />
</svg>
실행중 실행중
</button> </button>
) : ( ) : (
@@ -1035,17 +963,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
</div> </div>
{appActionMenuOpen && ( {appActionMenuOpen && (
<div className="launch-action-dropdown"> <div className="launch-action-dropdown">
{activeApp.trim().toLowerCase() === 'lms' && ( <button type="button" onClick={openDeleteConfirm} disabled={activeAppDeleting}>
<button type="button" onClick={handleWebLaunch}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2" />
<path d="M8 21h8" />
<path d="M12 17v4" />
</svg>
<span>웹 실행</span>
</button>
)}
<button type="button" className="launch-action-danger" onClick={openDeleteConfirm} disabled={activeAppDeleting}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h18" /> <path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
@@ -1076,7 +994,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
<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"> <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 <button
onClick={() => setShowManual(false)} onClick={() => setShowManual(false)}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover:bg-[rgba(255,255,255,0.06)] hover:text-[var(--text-primary)]" 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"> <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"/> <polyline points="15 18 9 12 15 6"/>
@@ -1085,7 +1003,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
<span className="text-[13px] font-medium text-[var(--text-secondary)]">사용 매뉴얼</span> <span className="text-[13px] font-medium text-[var(--text-secondary)]">사용 매뉴얼</span>
</div> </div>
<iframe <iframe
src={`${MANUAL_BASE_URL}/?appKey=${encodeURIComponent(activeApp)}`} src="appdocs://host/install.html"
className="absolute left-0 right-0 bottom-0 border-none" className="absolute left-0 right-0 bottom-0 border-none"
style={{ top: '48px', width: '100%', height: 'calc(100% - 48px)', background: '#0a0b0e' }} style={{ top: '48px', width: '100%', height: 'calc(100% - 48px)', background: '#0a0b0e' }}
/> />
+3 -8
View File
@@ -20,7 +20,6 @@ export interface AuthSession {
email: string email: string
name: string | null name: string | null
role: string role: string
loginMethod?: 'GOOGLE' | 'PASSWORD'
roles?: string[] roles?: string[]
createdAt?: string createdAt?: string
lastLoginAt?: string | null lastLoginAt?: string | null
@@ -88,8 +87,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
if (initialEmail) setEmail(initialEmail) if (initialEmail) setEmail(initialEmail)
}, [initialEmail]) }, [initialEmail])
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number, loginMethod: 'GOOGLE' | 'PASSWORD'): Promise<void> => { const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
session.user.loginMethod = loginMethod
storeLastLoginEmail(loginEmail.trim()) storeLastLoginEmail(loginEmail.trim())
if (persistLogin) { if (persistLogin) {
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session)) window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
@@ -131,7 +129,7 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
if (!response.ok) throw new Error('Google 로그인 세션을 확인할 수 없습니다.') if (!response.ok) throw new Error('Google 로그인 세션을 확인할 수 없습니다.')
const session = await response.json() as AuthSession const session = await response.json() as AuthSession
await completeLogin(session, session.user.email, keepLogin, startedAt, 'GOOGLE') await completeLogin(session, session.user.email, keepLogin, startedAt)
} catch (oauthError) { } catch (oauthError) {
setError(oauthError instanceof Error ? oauthError.message : 'Google 로그인에 실패했습니다.') setError(oauthError instanceof Error ? oauthError.message : 'Google 로그인에 실패했습니다.')
} finally { } finally {
@@ -182,13 +180,10 @@ function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange, in
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 429) {
throw new Error('로그인 시도가 잠시 제한되었습니다. 잠시 후 다시 시도해주세요.')
}
throw new Error('이메일 또는 비밀번호를 확인하세요.') throw new Error('이메일 또는 비밀번호를 확인하세요.')
} }
const session = await response.json() as AuthSession const session = await response.json() as AuthSession
await completeLogin(session, email, keepLogin, startedAt, 'PASSWORD') await completeLogin(session, email, keepLogin, startedAt)
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.') setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
} finally { } finally {
+1 -3
View File
@@ -2,9 +2,7 @@
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"], "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": { "compilerOptions": {
"composite": false, "composite": true,
"incremental": false,
"noEmit": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"types": ["electron-vite/node"] "types": ["electron-vite/node"]
} }
+1 -3
View File
@@ -2,9 +2,7 @@
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json", "extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": ["src/renderer/src/**/*"], "include": ["src/renderer/src/**/*"],
"compilerOptions": { "compilerOptions": {
"composite": false, "composite": true,
"incremental": false,
"noEmit": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"paths": { "paths": {