merge develop into release

This commit is contained in:
2026-07-15 14:07:50 +09:00
9 changed files with 1582 additions and 24 deletions
+19 -2
View File
@@ -1,18 +1,19 @@
{
"name": "autonomy-studio-launcher",
"version": "1.0.14",
"version": "1.0.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "autonomy-studio-launcher",
"version": "1.0.14",
"version": "1.0.27",
"hasInstallScript": true,
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.4",
"lucide-react": "^1.14.0",
"node-pty": "^1.1.0",
"tailwindcss": "^4.2.4"
},
"devDependencies": {
@@ -8307,6 +8308,22 @@
"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": {
"version": "2.0.38",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+1
View File
@@ -58,6 +58,7 @@
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.2.4",
"lucide-react": "^1.14.0",
"node-pty": "^1.1.0",
"tailwindcss": "^4.2.4"
},
"devDependencies": {
+29
View File
@@ -5,6 +5,7 @@ import { get as httpGet } from 'http'
import { get as httpsGet } from 'https'
import { spawn, exec, execFile, ChildProcess } from 'child_process'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { LmsWebBridge } from './lms-web-bridge'
protocol.registerSchemesAsPrivileged([
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
@@ -12,6 +13,7 @@ protocol.registerSchemesAsPrivileged([
let splash: BrowserWindow | null = null
let pendingGoogleOAuthPayload: { loginToken?: string; error?: string } | null = null
const lmsWebBridge = new LmsWebBridge()
type CommandResult = {
ok: boolean
@@ -954,6 +956,29 @@ function createWindow(): void {
ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus())
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
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-install', () => installTailscale())
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
@@ -1220,4 +1245,8 @@ if (!gotSingleInstanceLock) {
app.quit()
}
})
app.on('will-quit', () => {
void lmsWebBridge.stop()
})
}
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -4,6 +4,7 @@ import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import AppWindowShell from './pages/AppWindowShell'
import type { AuthSession } from './pages/LoginForm'
import { refreshLauncherSession } from './auth/sessionRefresh'
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(/\/+$/, '')
@@ -90,11 +91,7 @@ function App(): React.JSX.Element {
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)
refreshLauncherSession()
.then((session) => {
if (session?.accessToken) {
handleSessionUpdate(session)
+13 -1
View File
@@ -437,7 +437,7 @@ body,
padding: 9px 12px;
background: transparent;
border: 0;
color: #fca5a5;
color: var(--text-secondary);
font-family: 'NanumGothic', sans-serif;
font-size: 12px;
font-weight: 700;
@@ -446,6 +446,14 @@ body,
}
.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);
}
@@ -719,6 +727,10 @@ body,
line-height: 18px;
}
.login-version-wrap .launcher-update-text {
transform: translateY(-1px);
}
.app-version-wrap {
position: absolute;
right: 24px;
+21
View File
@@ -0,0 +1,21 @@
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
export function refreshLauncherSession(): Promise<AuthSession | null> {
if (pendingRefresh) return pendingRefresh
pendingRefresh = fetch(`${launcherApiBaseUrl}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
.then((response) => response.ok ? response.json() as Promise<AuthSession> : null)
.catch(() => null)
.finally(() => {
pendingRefresh = null
})
return pendingRefresh
}
+43 -16
View File
@@ -5,6 +5,7 @@ import Tooltip from '../components/Tooltip'
import AppView from './AppView'
import type { LauncherApp } from '../config/apps'
import type { AuthSession } from './LoginForm'
import { refreshLauncherSession } from '../auth/sessionRefresh'
const version = import.meta.env.VITE_APP_VERSION
@@ -17,6 +18,7 @@ interface DashboardProps {
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
const MANUAL_BASE_URL = (import.meta.env.VITE_MANUAL_BASE_URL || 'https://portal.autonomystudio.net/manual').replace(/\/+$/, '')
const LMS_WEB_BASE_URL = `${(import.meta.env.VITE_LMS_WEB_BASE_URL || 'http://localhost:5177/lms-web/').replace(/\/+$/, '')}/`
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
const IS_DEV = import.meta.env.DEV
@@ -186,14 +188,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
: null
const activeAppDisplayName = appStatus?.displayName || appConfig?.displayName || activeApp
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
const session = await refreshLauncherSession()
if (!session) return null
onSessionUpdate(session)
return session
}, [onSessionUpdate])
@@ -722,13 +718,8 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
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
const nextSession = await refreshLauncherSession()
if (nextSession) {
onSessionUpdate(nextSession)
response = await requestHandoff(nextSession.accessToken)
} else {
@@ -786,6 +777,32 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
}
}, [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(() => {
window.electron.ipcRenderer.send('focus-app-window', activeApp)
}, [activeApp])
@@ -968,7 +985,17 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
</div>
{appActionMenuOpen && (
<div className="launch-action-dropdown">
<button type="button" onClick={openDeleteConfirm} disabled={activeAppDeleting}>
{activeApp.trim().toLowerCase() === 'lms' && (
<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">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
File diff suppressed because one or more lines are too long