feat: add global AI guide to LMS launcher
This commit is contained in:
Generated
+19
-2
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "autonomy-studio-launcher",
|
"name": "autonomy-studio-launcher",
|
||||||
"version": "1.0.14",
|
"version": "1.0.27",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "autonomy-studio-launcher",
|
"name": "autonomy-studio-launcher",
|
||||||
"version": "1.0.14",
|
"version": "1.0.27",
|
||||||
"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": {
|
||||||
@@ -8307,6 +8308,22 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"@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": {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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'
|
||||||
|
|
||||||
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 } }
|
||||||
@@ -12,6 +13,7 @@ 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()
|
||||||
|
|
||||||
type CommandResult = {
|
type CommandResult = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -954,6 +956,29 @@ 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-ssh-key-ensure', () => ensureLauncherSshKey())
|
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
||||||
@@ -1217,4 +1242,8 @@ if (!gotSingleInstanceLock) {
|
|||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
void lmsWebBridge.stop()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ 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 { refreshLauncherSession } 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(/\/+$/, '')
|
||||||
@@ -90,11 +91,7 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
|
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
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)
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ body,
|
|||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: #fca5a5;
|
color: var(--text-secondary);
|
||||||
font-family: 'NanumGothic', sans-serif;
|
font-family: 'NanumGothic', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -446,6 +446,14 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +727,10 @@ 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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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
|
||||||
|
|
||||||
@@ -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 LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||||
const MANUAL_BASE_URL = (import.meta.env.VITE_MANUAL_BASE_URL || 'https://portal.autonomystudio.net/manual').replace(/\/+$/, '')
|
const MANUAL_BASE_URL = (import.meta.env.VITE_MANUAL_BASE_URL || 'https://portal.autonomystudio.net/manual').replace(/\/+$/, '')
|
||||||
|
const LMS_WEB_BASE_URL = `${(import.meta.env.VITE_LMS_WEB_BASE_URL || 'http://localhost:5177/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 IS_DEV = import.meta.env.DEV
|
const IS_DEV = import.meta.env.DEV
|
||||||
@@ -186,14 +188,8 @@ 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> => {
|
||||||
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
|
||||||
}, [onSessionUpdate])
|
}, [onSessionUpdate])
|
||||||
@@ -722,13 +718,8 @@ 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 refreshResponse = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
const nextSession = await refreshLauncherSession()
|
||||||
method: 'POST',
|
if (nextSession) {
|
||||||
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 {
|
||||||
@@ -786,6 +777,32 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
}
|
}
|
||||||
}, [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])
|
||||||
@@ -968,7 +985,17 @@ function Dashboard({ authSession, onSessionUpdate, onLogout, onSessionExpired }:
|
|||||||
</div>
|
</div>
|
||||||
{appActionMenuOpen && (
|
{appActionMenuOpen && (
|
||||||
<div className="launch-action-dropdown">
|
<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">
|
<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" />
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user