init studio launcher frontend
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Autonomy Studio Launcher
|
||||||
|
|
||||||
|
## 설치
|
||||||
|
|
||||||
|
R2에 업로드된 설치 스크립트가 있으면 아래 한 줄로 설치한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://R2_PUBLIC_URL/studio-artifacts/launcher/latest/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh`는 최신 `autonomy-studio-launcher.deb`를 내려받고 `apt install`로 설치한다.
|
||||||
|
|
||||||
|
로컬에 `.deb` 파일을 받은 경우에는 아래처럼 설치한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./autonomy-studio-launcher-1.0.2.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
다른 경로에 있는 파일이면 경로를 지정한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install /tmp/autonomy-studio-launcher.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
설치 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dpkg -l | grep -i autonomy
|
||||||
|
dpkg -L autonomy-studio-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## 삭제
|
||||||
|
|
||||||
|
런처만 삭제:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt remove autonomy-studio-launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
설정 파일까지 같이 삭제:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt purge autonomy-studio-launcher
|
||||||
|
sudo apt autoremove
|
||||||
|
```
|
||||||
|
|
||||||
|
삭제 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dpkg -l | grep -i autonomy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
Linux `.deb` 패키지 생성:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드 결과물은 기본적으로 `dist/` 아래에 생성된다.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
const { version } = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()]
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()]
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
publicDir: resolve('resources'),
|
||||||
|
define: {
|
||||||
|
'import.meta.env.VITE_APP_VERSION': JSON.stringify(version)
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve('src/renderer/src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react(), tailwindcss()]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "autonomy-studio-launcher",
|
||||||
|
"version": "1.0.2",
|
||||||
|
"description": "Autonomy Studio v2 Frontend",
|
||||||
|
"main": "./out/main/index.js",
|
||||||
|
"author": {
|
||||||
|
"name": "Autonomy Studio",
|
||||||
|
"email": "hooladhoon@gmail.com"
|
||||||
|
},
|
||||||
|
"homepage": "https://electron-vite.org",
|
||||||
|
"scripts": {
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
"start": "electron-vite preview",
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "npm run typecheck && electron-vite build",
|
||||||
|
"postinstall": "electron-builder install-app-deps",
|
||||||
|
"build:unpack": "npm run build && electron-builder --dir",
|
||||||
|
"build:win": "npm run build && electron-builder --win",
|
||||||
|
"build:mac": "electron-vite build && electron-builder --mac",
|
||||||
|
"build:linux": "npm run build && electron-builder --linux"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.autonomystudio.launcher",
|
||||||
|
"productName": "Autonomy Studio Launcher",
|
||||||
|
"artifactName": "autonomy-studio-launcher-${version}.${ext}",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": ["deb"],
|
||||||
|
"icon": "resources/icons",
|
||||||
|
"category": "Development"
|
||||||
|
},
|
||||||
|
"deb": {
|
||||||
|
"compression": "gz",
|
||||||
|
"depends": [
|
||||||
|
"xdotool"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
|
"tailwindcss": "^4.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
|
||||||
|
"@electron-toolkit/eslint-config-ts": "^2.0.0",
|
||||||
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"electron": "^35.0.0",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"electron-vite": "^3.1.0",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
self.__BUILD_MANIFEST = {
|
||||||
|
"__rewrites": {
|
||||||
|
"afterFiles": [],
|
||||||
|
"beforeFiles": [],
|
||||||
|
"fallback": []
|
||||||
|
},
|
||||||
|
"sortedPages": [
|
||||||
|
"/_app",
|
||||||
|
"/_error"
|
||||||
|
]
|
||||||
|
};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
self.__MIDDLEWARE_MATCHERS = [];self.__MIDDLEWARE_MATCHERS_CB && self.__MIDDLEWARE_MATCHERS_CB()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,33525,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"warnOnce",{enumerable:!0,get:function(){return n}});let n=e=>{}},18967,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={DecodeError:function(){return P},MiddlewareNotFoundError:function(){return O},MissingStaticPage:function(){return h},NormalizeError:function(){return E},PageNotFoundError:function(){return b},SP:function(){return m},ST:function(){return y},WEB_VITALS:function(){return i},execOnce:function(){return u},getDisplayName:function(){return l},getLocationOrigin:function(){return c},getURL:function(){return f},isAbsoluteUrl:function(){return a},isResSent:function(){return d},loadGetInitialProps:function(){return g},normalizeRepeatedSlashes:function(){return p},stringifyError:function(){return N}};for(var o in n)Object.defineProperty(t,o,{enumerable:!0,get:n[o]});let i=["CLS","FCP","FID","INP","LCP","TTFB"];function u(e){let r,t=!1;return(...n)=>(t||(t=!0,r=e(...n)),r)}let s=/^[a-zA-Z][a-zA-Z\d+\-.]*?:/,a=e=>s.test(e);function c(){let{protocol:e,hostname:r,port:t}=window.location;return`${e}//${r}${t?":"+t:""}`}function f(){let{href:e}=window.location,r=c();return e.substring(r.length)}function l(e){return"string"==typeof e?e:e.displayName||e.name||"Unknown"}function d(e){return e.finished||e.headersSent}function p(e){let r=e.split("?");return r[0].replace(/\\/g,"/").replace(/\/\/+/g,"/")+(r[1]?`?${r.slice(1).join("?")}`:"")}async function g(e,r){let t=r.res||r.ctx&&r.ctx.res;if(!e.getInitialProps)return r.ctx&&r.Component?{pageProps:await g(r.Component,r.ctx)}:{};let n=await e.getInitialProps(r);if(t&&d(t))return n;if(!n)throw Object.defineProperty(Error(`"${l(e)}.getInitialProps()" should resolve to an object. But found "${n}" instead.`),"__NEXT_ERROR_CODE",{value:"E1025",enumerable:!1,configurable:!0});return n}let m="u">typeof performance,y=m&&["mark","measure","getEntriesByName"].every(e=>"function"==typeof performance[e]);class P extends Error{}class E extends Error{}class b extends Error{constructor(e){super(),this.code="ENOENT",this.name="PageNotFoundError",this.message=`Cannot find module for page: ${e}`}}class h extends Error{constructor(e,r){super(),this.message=`Failed to load static file for page: ${e} ${r}`}}class O extends Error{constructor(){super(),this.code="ENOENT",this.message="Cannot find the middleware module"}}function N(e){return JSON.stringify({message:e.message,stack:e.stack})}},98183,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={assign:function(){return a},searchParamsToUrlQuery:function(){return i},urlQueryToSearchParams:function(){return s}};for(var o in n)Object.defineProperty(t,o,{enumerable:!0,get:n[o]});function i(e){let r={};for(let[t,n]of e.entries()){let e=r[t];void 0===e?r[t]=n:Array.isArray(e)?e.push(n):r[t]=[e,n]}return r}function u(e){return"string"==typeof e?e:("number"!=typeof e||isNaN(e))&&"boolean"!=typeof e?"":String(e)}function s(e){let r=new URLSearchParams;for(let[t,n]of Object.entries(e))if(Array.isArray(n))for(let e of n)r.append(t,u(e));else r.set(t,u(n));return r}function a(e,...r){for(let t of r){for(let r of t.keys())e.delete(r);for(let[r,n]of t.entries())e.append(r,n)}return e}}]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,98183,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={assign:function(){return a},searchParamsToUrlQuery:function(){return i},urlQueryToSearchParams:function(){return s}};for(var o in n)Object.defineProperty(t,o,{enumerable:!0,get:n[o]});function i(e){let r={};for(let[t,n]of e.entries()){let e=r[t];void 0===e?r[t]=n:Array.isArray(e)?e.push(n):r[t]=[e,n]}return r}function u(e){return"string"==typeof e?e:("number"!=typeof e||isNaN(e))&&"boolean"!=typeof e?"":String(e)}function s(e){let r=new URLSearchParams;for(let[t,n]of Object.entries(e))if(Array.isArray(n))for(let e of n)r.append(t,u(e));else r.set(t,u(n));return r}function a(e,...r){for(let t of r){for(let r of t.keys())e.delete(r);for(let[r,n]of t.entries())e.append(r,n)}return e}},18967,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n={DecodeError:function(){return P},MiddlewareNotFoundError:function(){return O},MissingStaticPage:function(){return h},NormalizeError:function(){return E},PageNotFoundError:function(){return b},SP:function(){return m},ST:function(){return y},WEB_VITALS:function(){return i},execOnce:function(){return u},getDisplayName:function(){return l},getLocationOrigin:function(){return c},getURL:function(){return f},isAbsoluteUrl:function(){return a},isResSent:function(){return d},loadGetInitialProps:function(){return g},normalizeRepeatedSlashes:function(){return p},stringifyError:function(){return N}};for(var o in n)Object.defineProperty(t,o,{enumerable:!0,get:n[o]});let i=["CLS","FCP","FID","INP","LCP","TTFB"];function u(e){let r,t=!1;return(...n)=>(t||(t=!0,r=e(...n)),r)}let s=/^[a-zA-Z][a-zA-Z\d+\-.]*?:/,a=e=>s.test(e);function c(){let{protocol:e,hostname:r,port:t}=window.location;return`${e}//${r}${t?":"+t:""}`}function f(){let{href:e}=window.location,r=c();return e.substring(r.length)}function l(e){return"string"==typeof e?e:e.displayName||e.name||"Unknown"}function d(e){return e.finished||e.headersSent}function p(e){let r=e.split("?");return r[0].replace(/\\/g,"/").replace(/\/\/+/g,"/")+(r[1]?`?${r.slice(1).join("?")}`:"")}async function g(e,r){let t=r.res||r.ctx&&r.ctx.res;if(!e.getInitialProps)return r.ctx&&r.Component?{pageProps:await g(r.Component,r.ctx)}:{};let n=await e.getInitialProps(r);if(t&&d(t))return n;if(!n)throw Object.defineProperty(Error(`"${l(e)}.getInitialProps()" should resolve to an object. But found "${n}" instead.`),"__NEXT_ERROR_CODE",{value:"E1025",enumerable:!1,configurable:!0});return n}let m="u">typeof performance,y=m&&["mark","measure","getEntriesByName"].every(e=>"function"==typeof performance[e]);class P extends Error{}class E extends Error{}class b extends Error{constructor(e){super(),this.code="ENOENT",this.name="PageNotFoundError",this.message=`Cannot find module for page: ${e}`}}class h extends Error{constructor(e,r){super(),this.message=`Failed to load static file for page: ${e} ${r}`}}class O extends Error{constructor(){super(),this.code="ENOENT",this.message="Cannot find the middleware module"}}function N(e){return JSON.stringify({message:e.message,stack:e.stack})}},33525,(e,r,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"warnOnce",{enumerable:!0,get:function(){return n}});let n=e=>{}}]);
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
3:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
4:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
5:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"OutletBoundary"]
|
||||||
|
6:"$Sreact.suspense"
|
||||||
|
9:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ViewportBoundary"]
|
||||||
|
b:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MetadataBoundary"]
|
||||||
|
d:I[68027,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default",1]
|
||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"P":null,"c":["","_not-found"],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0x.~j7vy9ohz0.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0tsl9e86lbpo2.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/_next/static/chunks/07xlws-230erd.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/_next/static/chunks/0e6i-ghtpo-w_.js","async":true,"nonce":"$undefined"}]],"$L2"]}],{"children":[["$","$1","c",{"children":[null,["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L5",null,{"children":["$","$6",null,{"name":"Next.MetadataOutlet","children":"$@7"}]}]]}],{},null,false,null]},null,false,"$@8"]},null,false,null],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L9",null,{"children":"$La"}],["$","div",null,{"hidden":true,"children":["$","$Lb",null,{"children":["$","$6",null,{"name":"Next.Metadata","children":"$Lc"}]}]}],null]}],false]],"m":"$undefined","G":["$d",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
|
e:[]
|
||||||
|
8:"$We"
|
||||||
|
f:I[68386,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeConfigProvider"]
|
||||||
|
10:I[6344,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"LastUpdated"]
|
||||||
|
11:I[98091,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Search"]
|
||||||
|
12:I[63178,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeProvider"]
|
||||||
|
13:I[91675,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"SkipNavLink"]
|
||||||
|
14:I[37985,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ConfigProvider"]
|
||||||
|
15:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MobileNav"]
|
||||||
|
2:["$","html",null,{"lang":"ko","dir":"ltr","suppressHydrationWarning":true,"className":"dark","style":{"colorScheme":"dark"},"children":[["$","head",null,{"children":["$undefined",["$","style",null,{"children":":root {\n --nextra-primary-hue: 212deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 45%;\n --nextra-bg: 250,250,250;\n --nextra-content-width: 90rem;\n}\n.dark {\n --nextra-primary-hue: 204deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 55%;\n --nextra-bg: 17,17,17;\n}\n::selection {\n background: hsla(var(--nextra-primary-hue),var(--nextra-primary-saturation),var(--nextra-primary-lightness),.3);\n}\nhtml {\n background: rgb(var(--nextra-bg));\n}"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: light)","content":"rgb(250,250,250)"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: dark)","content":"rgb(17,17,17)"}],"$undefined"]}],["$","body",null,{"className":"dark bg-[#0b0f14]","children":["$","$Lf",null,{"value":{"copyPageButton":true,"darkMode":true,"docsRepositoryBase":"https://github.com/shuding/nextra","editLink":"Edit this page","feedback":{"content":"Question? Give us feedback","labels":"feedback"},"i18n":[],"lastUpdated":["$","$L10",null,{}],"navigation":{"next":true,"prev":true},"search":["$","$L11",null,{}],"sidebar":{"defaultMenuCollapseLevel":2,"defaultOpen":true,"toggleButton":true},"themeSwitch":{"dark":"Dark","light":"Light","system":"System"},"toc":{"backToTop":"Scroll to top","float":true,"title":"On This Page"}},"children":["$","$L12",null,{"attribute":"class","defaultTheme":"system","disableTransitionOnChange":true,"storageKey":"theme","children":[["$","$L13",null,{}],"$undefined",["$","$L14",null,{"pageMap":[{"data":{"*":{"theme":{"copyPage":false,"footer":false,"navbar":false,"toc":false}},"install":{"title":"설치하기"},"run":{"title":"실행하기"}}},{"name":"install","route":"/install","frontMatter":{"title":"Page","filePath":"app/install/page.mdx"},"title":"설치하기"},{"name":"run","route":"/run","frontMatter":{"title":"Page","filePath":"app/run/page.mdx"},"title":"실행하기"}],"navbar":"$undefined","footer":"$undefined","children":[["$","$L15",null,{}],["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:1:children:1:children:0:props:children:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:1:children:1:children:0:props:children:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:1:children:1:children:0:props:children:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:1:children:1:children:0:props:children:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]]}]}]}]]}]
|
||||||
|
a:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
|
16:I[27201,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"IconMark"]
|
||||||
|
7:null
|
||||||
|
c:[["$","title","0",{"children":"Autonomy Studio"}],["$","meta","1",{"name":"description","content":"Autonomy Studio Manual"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L16","3",{}]]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
3:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
4:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
5:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"OutletBoundary"]
|
||||||
|
6:"$Sreact.suspense"
|
||||||
|
9:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ViewportBoundary"]
|
||||||
|
b:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MetadataBoundary"]
|
||||||
|
d:I[68027,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default",1]
|
||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"P":null,"c":["","_not-found"],"q":"","i":false,"f":[[["",{"children":["/_not-found",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0x.~j7vy9ohz0.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0tsl9e86lbpo2.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/_next/static/chunks/07xlws-230erd.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/_next/static/chunks/0e6i-ghtpo-w_.js","async":true,"nonce":"$undefined"}]],"$L2"]}],{"children":[["$","$1","c",{"children":[null,["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L5",null,{"children":["$","$6",null,{"name":"Next.MetadataOutlet","children":"$@7"}]}]]}],{},null,false,null]},null,false,"$@8"]},null,false,null],["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L9",null,{"children":"$La"}],["$","div",null,{"hidden":true,"children":["$","$Lb",null,{"children":["$","$6",null,{"name":"Next.Metadata","children":"$Lc"}]}]}],null]}],false]],"m":"$undefined","G":["$d",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
|
e:[]
|
||||||
|
8:"$We"
|
||||||
|
f:I[68386,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeConfigProvider"]
|
||||||
|
10:I[6344,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"LastUpdated"]
|
||||||
|
11:I[98091,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Search"]
|
||||||
|
12:I[63178,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeProvider"]
|
||||||
|
13:I[91675,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"SkipNavLink"]
|
||||||
|
14:I[37985,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ConfigProvider"]
|
||||||
|
15:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MobileNav"]
|
||||||
|
2:["$","html",null,{"lang":"ko","dir":"ltr","suppressHydrationWarning":true,"className":"dark","style":{"colorScheme":"dark"},"children":[["$","head",null,{"children":["$undefined",["$","style",null,{"children":":root {\n --nextra-primary-hue: 212deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 45%;\n --nextra-bg: 250,250,250;\n --nextra-content-width: 90rem;\n}\n.dark {\n --nextra-primary-hue: 204deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 55%;\n --nextra-bg: 17,17,17;\n}\n::selection {\n background: hsla(var(--nextra-primary-hue),var(--nextra-primary-saturation),var(--nextra-primary-lightness),.3);\n}\nhtml {\n background: rgb(var(--nextra-bg));\n}"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: light)","content":"rgb(250,250,250)"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: dark)","content":"rgb(17,17,17)"}],"$undefined"]}],["$","body",null,{"className":"dark bg-[#0b0f14]","children":["$","$Lf",null,{"value":{"copyPageButton":true,"darkMode":true,"docsRepositoryBase":"https://github.com/shuding/nextra","editLink":"Edit this page","feedback":{"content":"Question? Give us feedback","labels":"feedback"},"i18n":[],"lastUpdated":["$","$L10",null,{}],"navigation":{"next":true,"prev":true},"search":["$","$L11",null,{}],"sidebar":{"defaultMenuCollapseLevel":2,"defaultOpen":true,"toggleButton":true},"themeSwitch":{"dark":"Dark","light":"Light","system":"System"},"toc":{"backToTop":"Scroll to top","float":true,"title":"On This Page"}},"children":["$","$L12",null,{"attribute":"class","defaultTheme":"system","disableTransitionOnChange":true,"storageKey":"theme","children":[["$","$L13",null,{}],"$undefined",["$","$L14",null,{"pageMap":[{"data":{"*":{"theme":{"copyPage":false,"footer":false,"navbar":false,"toc":false}},"install":{"title":"설치하기"},"run":{"title":"실행하기"}}},{"name":"install","route":"/install","frontMatter":{"title":"Page","filePath":"app/install/page.mdx"},"title":"설치하기"},{"name":"run","route":"/run","frontMatter":{"title":"Page","filePath":"app/run/page.mdx"},"title":"실행하기"}],"navbar":"$undefined","footer":"$undefined","children":[["$","$L15",null,{}],["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:f:0:1:1:children:1:children:0:props:children:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$0:f:0:1:1:children:1:children:0:props:children:0:1:props:children:props:children:1:props:style","children":404}],["$","div",null,{"style":"$0:f:0:1:1:children:1:children:0:props:children:0:1:props:children:props:children:2:props:style","children":["$","h2",null,{"style":"$0:f:0:1:1:children:1:children:0:props:children:0:1:props:children:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]]}]}]}]]}]
|
||||||
|
a:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
|
16:I[27201,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"IconMark"]
|
||||||
|
7:null
|
||||||
|
c:[["$","title","0",{"children":"Autonomy Studio"}],["$","meta","1",{"name":"description","content":"Autonomy Studio Manual"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L16","3",{}]]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ViewportBoundary"]
|
||||||
|
3:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MetadataBoundary"]
|
||||||
|
4:"$Sreact.suspense"
|
||||||
|
5:I[27201,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"IconMark"]
|
||||||
|
0:{"rsc":["$","$1","h",{"children":[["$","meta",null,{"name":"robots","content":"noindex"}],["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Autonomy Studio"}],["$","meta","1",{"name":"description","content":"Autonomy Studio Manual"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L5","3",{}]]}]}]}],null]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[68386,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeConfigProvider"]
|
||||||
|
3:I[6344,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"LastUpdated"]
|
||||||
|
4:I[98091,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Search"]
|
||||||
|
5:I[63178,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeProvider"]
|
||||||
|
6:I[91675,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"SkipNavLink"]
|
||||||
|
7:I[37985,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ConfigProvider"]
|
||||||
|
8:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MobileNav"]
|
||||||
|
9:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
a:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0x.~j7vy9ohz0.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/0tsl9e86lbpo2.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true}],["$","script","script-3",{"src":"/_next/static/chunks/07xlws-230erd.js","async":true}],["$","script","script-4",{"src":"/_next/static/chunks/0e6i-ghtpo-w_.js","async":true}]],["$","html",null,{"lang":"ko","dir":"ltr","suppressHydrationWarning":true,"className":"dark","style":{"colorScheme":"dark"},"children":[["$","head",null,{"children":["$undefined",["$","style",null,{"children":":root {\n --nextra-primary-hue: 212deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 45%;\n --nextra-bg: 250,250,250;\n --nextra-content-width: 90rem;\n}\n.dark {\n --nextra-primary-hue: 204deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 55%;\n --nextra-bg: 17,17,17;\n}\n::selection {\n background: hsla(var(--nextra-primary-hue),var(--nextra-primary-saturation),var(--nextra-primary-lightness),.3);\n}\nhtml {\n background: rgb(var(--nextra-bg));\n}"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: light)","content":"rgb(250,250,250)"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: dark)","content":"rgb(17,17,17)"}],"$undefined"]}],["$","body",null,{"className":"dark bg-[#0b0f14]","children":["$","$L2",null,{"value":{"copyPageButton":true,"darkMode":true,"docsRepositoryBase":"https://github.com/shuding/nextra","editLink":"Edit this page","feedback":{"content":"Question? Give us feedback","labels":"feedback"},"i18n":[],"lastUpdated":["$","$L3",null,{}],"navigation":{"next":true,"prev":true},"search":["$","$L4",null,{}],"sidebar":{"defaultMenuCollapseLevel":2,"defaultOpen":true,"toggleButton":true},"themeSwitch":{"dark":"Dark","light":"Light","system":"System"},"toc":{"backToTop":"Scroll to top","float":true,"title":"On This Page"}},"children":["$","$L5",null,{"attribute":"class","defaultTheme":"system","disableTransitionOnChange":true,"storageKey":"theme","children":[["$","$L6",null,{}],"$undefined",["$","$L7",null,{"pageMap":[{"data":{"*":{"theme":{"copyPage":false,"footer":false,"navbar":false,"toc":false}},"install":{"title":"설치하기"},"run":{"title":"실행하기"}}},{"name":"install","route":"/install","frontMatter":{"title":"Page","filePath":"app/install/page.mdx"},"title":"설치하기"},{"name":"run","route":"/run","frontMatter":{"title":"Page","filePath":"app/run/page.mdx"},"title":"실행하기"}],"children":[["$","$L8",null,{}],["$","$L9",null,{"parallelRouterKey":"children","template":["$","$La",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]]}]]}]}]}]]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"OutletBoundary"]
|
||||||
|
3:"$Sreact.suspense"
|
||||||
|
0:{"rsc":["$","$1","c",{"children":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],null,["$","$L2",null,{"children":["$","$3",null,{"name":"Next.MetadataOutlet","children":"$@4"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
|
4:null
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
3:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
4:[]
|
||||||
|
0:{"rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"isPartial":false,"staleTime":300,"varyParams":"$W4","buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"/_not-found","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ViewportBoundary"]
|
||||||
|
3:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MetadataBoundary"]
|
||||||
|
4:"$Sreact.suspense"
|
||||||
|
5:I[27201,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"IconMark"]
|
||||||
|
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Page"}],["$","meta","1",{"name":"description","content":"Autonomy Studio Manual"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L5","3",{}]]}]}]}],null]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[68386,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeConfigProvider"]
|
||||||
|
3:I[6344,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"LastUpdated"]
|
||||||
|
4:I[98091,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Search"]
|
||||||
|
5:I[63178,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeProvider"]
|
||||||
|
6:I[91675,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"SkipNavLink"]
|
||||||
|
7:I[37985,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ConfigProvider"]
|
||||||
|
8:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MobileNav"]
|
||||||
|
9:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
a:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0x.~j7vy9ohz0.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/0tsl9e86lbpo2.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true}],["$","script","script-3",{"src":"/_next/static/chunks/07xlws-230erd.js","async":true}],["$","script","script-4",{"src":"/_next/static/chunks/0e6i-ghtpo-w_.js","async":true}]],["$","html",null,{"lang":"ko","dir":"ltr","suppressHydrationWarning":true,"className":"dark","style":{"colorScheme":"dark"},"children":[["$","head",null,{"children":["$undefined",["$","style",null,{"children":":root {\n --nextra-primary-hue: 212deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 45%;\n --nextra-bg: 250,250,250;\n --nextra-content-width: 90rem;\n}\n.dark {\n --nextra-primary-hue: 204deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 55%;\n --nextra-bg: 17,17,17;\n}\n::selection {\n background: hsla(var(--nextra-primary-hue),var(--nextra-primary-saturation),var(--nextra-primary-lightness),.3);\n}\nhtml {\n background: rgb(var(--nextra-bg));\n}"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: light)","content":"rgb(250,250,250)"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: dark)","content":"rgb(17,17,17)"}],"$undefined"]}],["$","body",null,{"className":"dark bg-[#0b0f14]","children":["$","$L2",null,{"value":{"copyPageButton":true,"darkMode":true,"docsRepositoryBase":"https://github.com/shuding/nextra","editLink":"Edit this page","feedback":{"content":"Question? Give us feedback","labels":"feedback"},"i18n":[],"lastUpdated":["$","$L3",null,{}],"navigation":{"next":true,"prev":true},"search":["$","$L4",null,{}],"sidebar":{"defaultMenuCollapseLevel":2,"defaultOpen":true,"toggleButton":true},"themeSwitch":{"dark":"Dark","light":"Light","system":"System"},"toc":{"backToTop":"Scroll to top","float":true,"title":"On This Page"}},"children":["$","$L5",null,{"attribute":"class","defaultTheme":"system","disableTransitionOnChange":true,"storageKey":"theme","children":[["$","$L6",null,{}],"$undefined",["$","$L7",null,{"pageMap":[{"data":{"*":{"theme":{"copyPage":false,"footer":false,"navbar":false,"toc":false}},"install":{"title":"설치하기"},"run":{"title":"실행하기"}}},{"name":"install","route":"/install","frontMatter":{"title":"Page","filePath":"app/install/page.mdx"},"title":"설치하기"},{"name":"run","route":"/run","frontMatter":{"title":"Page","filePath":"app/run/page.mdx"},"title":"실행하기"}],"children":[["$","$L8",null,{}],["$","$L9",null,{"parallelRouterKey":"children","template":["$","$La",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]]}]]}]}]}]]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"install","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
3:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
4:[]
|
||||||
|
0:{"rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"isPartial":false,"staleTime":300,"varyParams":"$W4","buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
3:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
4:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
5:I[55169,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"TOCProvider"]
|
||||||
|
6:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Sidebar"]
|
||||||
|
7:I[47486,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ClientWrapper"]
|
||||||
|
8:I[63894,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"HeadingAnchor"]
|
||||||
|
9:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"OutletBoundary"]
|
||||||
|
a:"$Sreact.suspense"
|
||||||
|
d:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ViewportBoundary"]
|
||||||
|
f:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MetadataBoundary"]
|
||||||
|
11:I[68027,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default",1]
|
||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"P":null,"c":["","run"],"q":"","i":false,"f":[[["",{"children":["run",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0x.~j7vy9ohz0.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0tsl9e86lbpo2.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/_next/static/chunks/07xlws-230erd.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/_next/static/chunks/0e6i-ghtpo-w_.js","async":true,"nonce":"$undefined"}]],"$L2"]}],{"children":[["$","$1","c",{"children":[null,["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","div",null,{"className":"x:mx-auto x:flex x:max-w-(--nextra-content-width)","children":["$","$L5",null,{"value":[{"value":"자율주행 알고리즘 런처","id":"자율주행-알고리즘-런처","depth":2},{"value":"주요 기능","id":"주요-기능","depth":3}],"children":[["$","$L6",null,{}],["$","$L7",null,{"metadata":{"title":"Page","filePath":"app/run/page.mdx"},"bottomContent":"$undefined","sourceCode":"## 자율주행 알고리즘 런처\n\n자율주행 알고리즘 런처는 다양한 카테고리의 알고리즘을 \n손쉽게 실행하고 관리할 수 있는 도구입니다.\n\n### 주요 기능\n\n- 여러 카테고리의 알고리즘을 한 번에 실행 및 종료\n- 특정 카테고리 알고리즘만 선택적으로 제어 가능\n- 다른 알고리즘으로 교체 실행 가능\n- 실행 환경 설정 저장 및 재사용","children":[["$","div",null,{"id":"nextra-skip-nav"}],["$","main",null,{"data-pagefind-body":true,"children":[["$","h2",null,{"id":"자율주행-알고리즘-런처","className":"x:tracking-tight x:text-slate-900 x:dark:text-slate-100 x:font-semibold x:target:animate-[fade-in_1.5s] x:mt-10 x:border-b x:pb-1 x:text-3xl nextra-border","children":["자율주행 알고리즘 런처",["$","$L8",null,{"id":"자율주행-알고리즘-런처"}]]}],"\n",["$","p",null,{"className":"x:not-first:mt-[1.25em] x:leading-7","children":["자율주행 알고리즘 런처는 다양한 카테고리의 알고리즘을",["$","br",null,{}],"\n","손쉽게 실행하고 관리할 수 있는 도구입니다."]}],"\n",["$","h3",null,{"id":"주요-기능","className":"x:tracking-tight x:text-slate-900 x:dark:text-slate-100 x:font-semibold x:target:animate-[fade-in_1.5s] x:mt-8 x:text-2xl","children":["주요 기능",["$","$L8",null,{"id":"주요-기능"}]]}],"\n",["$","ul",null,{"className":"x:[:is(ol,ul)_&]:my-[.75em] x:not-first:mt-[1.25em] x:list-disc x:ms-[1.5em]","children":["\n",["$","li",null,{"className":"x:my-[.5em]","children":"여러 카테고리의 알고리즘을 한 번에 실행 및 종료"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"특정 카테고리 알고리즘만 선택적으로 제어 가능"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"다른 알고리즘으로 교체 실행 가능"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"실행 환경 설정 저장 및 재사용"}],"\n"]}]]}]]}]]}]}],null,["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,null]},null,false,"$@c"]},null,false,null],["$","$1","h",{"children":[null,["$","$Ld",null,{"children":"$Le"}],["$","div",null,{"hidden":true,"children":["$","$Lf",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$L10"}]}]}],null]}],false]],"m":"$undefined","G":["$11",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
|
12:[]
|
||||||
|
c:"$W12"
|
||||||
|
13:I[68386,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeConfigProvider"]
|
||||||
|
14:I[6344,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"LastUpdated"]
|
||||||
|
15:I[98091,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Search"]
|
||||||
|
16:I[63178,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeProvider"]
|
||||||
|
17:I[91675,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"SkipNavLink"]
|
||||||
|
18:I[37985,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ConfigProvider"]
|
||||||
|
19:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MobileNav"]
|
||||||
|
2:["$","html",null,{"lang":"ko","dir":"ltr","suppressHydrationWarning":true,"className":"dark","style":{"colorScheme":"dark"},"children":[["$","head",null,{"children":["$undefined",["$","style",null,{"children":":root {\n --nextra-primary-hue: 212deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 45%;\n --nextra-bg: 250,250,250;\n --nextra-content-width: 90rem;\n}\n.dark {\n --nextra-primary-hue: 204deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 55%;\n --nextra-bg: 17,17,17;\n}\n::selection {\n background: hsla(var(--nextra-primary-hue),var(--nextra-primary-saturation),var(--nextra-primary-lightness),.3);\n}\nhtml {\n background: rgb(var(--nextra-bg));\n}"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: light)","content":"rgb(250,250,250)"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: dark)","content":"rgb(17,17,17)"}],"$undefined"]}],["$","body",null,{"className":"dark bg-[#0b0f14]","children":["$","$L13",null,{"value":{"copyPageButton":true,"darkMode":true,"docsRepositoryBase":"https://github.com/shuding/nextra","editLink":"Edit this page","feedback":{"content":"Question? Give us feedback","labels":"feedback"},"i18n":[],"lastUpdated":["$","$L14",null,{}],"navigation":{"next":true,"prev":true},"search":["$","$L15",null,{}],"sidebar":{"defaultMenuCollapseLevel":2,"defaultOpen":true,"toggleButton":true},"themeSwitch":{"dark":"Dark","light":"Light","system":"System"},"toc":{"backToTop":"Scroll to top","float":true,"title":"On This Page"}},"children":["$","$L16",null,{"attribute":"class","defaultTheme":"system","disableTransitionOnChange":true,"storageKey":"theme","children":[["$","$L17",null,{}],"$undefined",["$","$L18",null,{"pageMap":[{"data":{"*":{"theme":{"copyPage":false,"footer":false,"navbar":false,"toc":false}},"install":{"title":"설치하기"},"run":{"title":"실행하기"}}},{"name":"install","route":"/install","frontMatter":{"title":"Page","filePath":"app/install/page.mdx"},"title":"설치하기"},{"name":"run","route":"/run","frontMatter":{"title":"Page","filePath":"app/run/page.mdx"},"title":"실행하기"}],"navbar":"$undefined","footer":"$undefined","children":[["$","$L19",null,{}],["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]]}]}]}]]}]
|
||||||
|
e:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
|
1a:I[27201,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"IconMark"]
|
||||||
|
b:null
|
||||||
|
10:[["$","title","0",{"children":"Page"}],["$","meta","1",{"name":"description","content":"Autonomy Studio Manual"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L1a","3",{}]]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
3:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
4:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
5:I[55169,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"TOCProvider"]
|
||||||
|
6:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Sidebar"]
|
||||||
|
7:I[47486,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ClientWrapper"]
|
||||||
|
8:I[63894,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"HeadingAnchor"]
|
||||||
|
9:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"OutletBoundary"]
|
||||||
|
a:"$Sreact.suspense"
|
||||||
|
d:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ViewportBoundary"]
|
||||||
|
f:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MetadataBoundary"]
|
||||||
|
11:I[68027,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default",1]
|
||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"P":null,"c":["","run"],"q":"","i":false,"f":[[["",{"children":["run",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/0x.~j7vy9ohz0.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0tsl9e86lbpo2.js","async":true,"nonce":"$undefined"}],["$","script","script-2",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true,"nonce":"$undefined"}],["$","script","script-3",{"src":"/_next/static/chunks/07xlws-230erd.js","async":true,"nonce":"$undefined"}],["$","script","script-4",{"src":"/_next/static/chunks/0e6i-ghtpo-w_.js","async":true,"nonce":"$undefined"}]],"$L2"]}],{"children":[["$","$1","c",{"children":[null,["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","div",null,{"className":"x:mx-auto x:flex x:max-w-(--nextra-content-width)","children":["$","$L5",null,{"value":[{"value":"자율주행 알고리즘 런처","id":"자율주행-알고리즘-런처","depth":2},{"value":"주요 기능","id":"주요-기능","depth":3}],"children":[["$","$L6",null,{}],["$","$L7",null,{"metadata":{"title":"Page","filePath":"app/run/page.mdx"},"bottomContent":"$undefined","sourceCode":"## 자율주행 알고리즘 런처\n\n자율주행 알고리즘 런처는 다양한 카테고리의 알고리즘을 \n손쉽게 실행하고 관리할 수 있는 도구입니다.\n\n### 주요 기능\n\n- 여러 카테고리의 알고리즘을 한 번에 실행 및 종료\n- 특정 카테고리 알고리즘만 선택적으로 제어 가능\n- 다른 알고리즘으로 교체 실행 가능\n- 실행 환경 설정 저장 및 재사용","children":[["$","div",null,{"id":"nextra-skip-nav"}],["$","main",null,{"data-pagefind-body":true,"children":[["$","h2",null,{"id":"자율주행-알고리즘-런처","className":"x:tracking-tight x:text-slate-900 x:dark:text-slate-100 x:font-semibold x:target:animate-[fade-in_1.5s] x:mt-10 x:border-b x:pb-1 x:text-3xl nextra-border","children":["자율주행 알고리즘 런처",["$","$L8",null,{"id":"자율주행-알고리즘-런처"}]]}],"\n",["$","p",null,{"className":"x:not-first:mt-[1.25em] x:leading-7","children":["자율주행 알고리즘 런처는 다양한 카테고리의 알고리즘을",["$","br",null,{}],"\n","손쉽게 실행하고 관리할 수 있는 도구입니다."]}],"\n",["$","h3",null,{"id":"주요-기능","className":"x:tracking-tight x:text-slate-900 x:dark:text-slate-100 x:font-semibold x:target:animate-[fade-in_1.5s] x:mt-8 x:text-2xl","children":["주요 기능",["$","$L8",null,{"id":"주요-기능"}]]}],"\n",["$","ul",null,{"className":"x:[:is(ol,ul)_&]:my-[.75em] x:not-first:mt-[1.25em] x:list-disc x:ms-[1.5em]","children":["\n",["$","li",null,{"className":"x:my-[.5em]","children":"여러 카테고리의 알고리즘을 한 번에 실행 및 종료"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"특정 카테고리 알고리즘만 선택적으로 제어 가능"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"다른 알고리즘으로 교체 실행 가능"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"실행 환경 설정 저장 및 재사용"}],"\n"]}]]}]]}]]}]}],null,["$","$L9",null,{"children":["$","$a",null,{"name":"Next.MetadataOutlet","children":"$@b"}]}]]}],{},null,false,null]},null,false,"$@c"]},null,false,null],["$","$1","h",{"children":[null,["$","$Ld",null,{"children":"$Le"}],["$","div",null,{"hidden":true,"children":["$","$Lf",null,{"children":["$","$a",null,{"name":"Next.Metadata","children":"$L10"}]}]}],null]}],false]],"m":"$undefined","G":["$11",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
|
12:[]
|
||||||
|
c:"$W12"
|
||||||
|
13:I[68386,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeConfigProvider"]
|
||||||
|
14:I[6344,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"LastUpdated"]
|
||||||
|
15:I[98091,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Search"]
|
||||||
|
16:I[63178,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeProvider"]
|
||||||
|
17:I[91675,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"SkipNavLink"]
|
||||||
|
18:I[37985,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ConfigProvider"]
|
||||||
|
19:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MobileNav"]
|
||||||
|
2:["$","html",null,{"lang":"ko","dir":"ltr","suppressHydrationWarning":true,"className":"dark","style":{"colorScheme":"dark"},"children":[["$","head",null,{"children":["$undefined",["$","style",null,{"children":":root {\n --nextra-primary-hue: 212deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 45%;\n --nextra-bg: 250,250,250;\n --nextra-content-width: 90rem;\n}\n.dark {\n --nextra-primary-hue: 204deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 55%;\n --nextra-bg: 17,17,17;\n}\n::selection {\n background: hsla(var(--nextra-primary-hue),var(--nextra-primary-saturation),var(--nextra-primary-lightness),.3);\n}\nhtml {\n background: rgb(var(--nextra-bg));\n}"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: light)","content":"rgb(250,250,250)"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: dark)","content":"rgb(17,17,17)"}],"$undefined"]}],["$","body",null,{"className":"dark bg-[#0b0f14]","children":["$","$L13",null,{"value":{"copyPageButton":true,"darkMode":true,"docsRepositoryBase":"https://github.com/shuding/nextra","editLink":"Edit this page","feedback":{"content":"Question? Give us feedback","labels":"feedback"},"i18n":[],"lastUpdated":["$","$L14",null,{}],"navigation":{"next":true,"prev":true},"search":["$","$L15",null,{}],"sidebar":{"defaultMenuCollapseLevel":2,"defaultOpen":true,"toggleButton":true},"themeSwitch":{"dark":"Dark","light":"Light","system":"System"},"toc":{"backToTop":"Scroll to top","float":true,"title":"On This Page"}},"children":["$","$L16",null,{"attribute":"class","defaultTheme":"system","disableTransitionOnChange":true,"storageKey":"theme","children":[["$","$L17",null,{}],"$undefined",["$","$L18",null,{"pageMap":[{"data":{"*":{"theme":{"copyPage":false,"footer":false,"navbar":false,"toc":false}},"install":{"title":"설치하기"},"run":{"title":"실행하기"}}},{"name":"install","route":"/install","frontMatter":{"title":"Page","filePath":"app/install/page.mdx"},"title":"설치하기"},{"name":"run","route":"/run","frontMatter":{"title":"Page","filePath":"app/run/page.mdx"},"title":"실행하기"}],"navbar":"$undefined","footer":"$undefined","children":[["$","$L19",null,{}],["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]]}]}]}]]}]
|
||||||
|
e:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||||
|
1a:I[27201,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"IconMark"]
|
||||||
|
b:null
|
||||||
|
10:[["$","title","0",{"children":"Page"}],["$","meta","1",{"name":"description","content":"Autonomy Studio Manual"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L1a","3",{}]]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ViewportBoundary"]
|
||||||
|
3:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MetadataBoundary"]
|
||||||
|
4:"$Sreact.suspense"
|
||||||
|
5:I[27201,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"IconMark"]
|
||||||
|
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"Page"}],["$","meta","1",{"name":"description","content":"Autonomy Studio Manual"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.0x3dzn~oxb6tn.ico","sizes":"256x256","type":"image/x-icon"}],["$","$L5","3",{}]]}]}]}],null]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[68386,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeConfigProvider"]
|
||||||
|
3:I[6344,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"LastUpdated"]
|
||||||
|
4:I[98091,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Search"]
|
||||||
|
5:I[63178,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ThemeProvider"]
|
||||||
|
6:I[91675,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"SkipNavLink"]
|
||||||
|
7:I[37985,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ConfigProvider"]
|
||||||
|
8:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"MobileNav"]
|
||||||
|
9:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
a:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/13o8zmp072q8t.css","precedence":"next"}],["$","script","script-0",{"src":"/_next/static/chunks/0x.~j7vy9ohz0.js","async":true}],["$","script","script-1",{"src":"/_next/static/chunks/0tsl9e86lbpo2.js","async":true}],["$","script","script-2",{"src":"/_next/static/chunks/0d3shmwh5_nmn.js","async":true}],["$","script","script-3",{"src":"/_next/static/chunks/07xlws-230erd.js","async":true}],["$","script","script-4",{"src":"/_next/static/chunks/0e6i-ghtpo-w_.js","async":true}]],["$","html",null,{"lang":"ko","dir":"ltr","suppressHydrationWarning":true,"className":"dark","style":{"colorScheme":"dark"},"children":[["$","head",null,{"children":["$undefined",["$","style",null,{"children":":root {\n --nextra-primary-hue: 212deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 45%;\n --nextra-bg: 250,250,250;\n --nextra-content-width: 90rem;\n}\n.dark {\n --nextra-primary-hue: 204deg;\n --nextra-primary-saturation: 100%;\n --nextra-primary-lightness: 55%;\n --nextra-bg: 17,17,17;\n}\n::selection {\n background: hsla(var(--nextra-primary-hue),var(--nextra-primary-saturation),var(--nextra-primary-lightness),.3);\n}\nhtml {\n background: rgb(var(--nextra-bg));\n}"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: light)","content":"rgb(250,250,250)"}],["$","meta",null,{"name":"theme-color","media":"(prefers-color-scheme: dark)","content":"rgb(17,17,17)"}],"$undefined"]}],["$","body",null,{"className":"dark bg-[#0b0f14]","children":["$","$L2",null,{"value":{"copyPageButton":true,"darkMode":true,"docsRepositoryBase":"https://github.com/shuding/nextra","editLink":"Edit this page","feedback":{"content":"Question? Give us feedback","labels":"feedback"},"i18n":[],"lastUpdated":["$","$L3",null,{}],"navigation":{"next":true,"prev":true},"search":["$","$L4",null,{}],"sidebar":{"defaultMenuCollapseLevel":2,"defaultOpen":true,"toggleButton":true},"themeSwitch":{"dark":"Dark","light":"Light","system":"System"},"toc":{"backToTop":"Scroll to top","float":true,"title":"On This Page"}},"children":["$","$L5",null,{"attribute":"class","defaultTheme":"system","disableTransitionOnChange":true,"storageKey":"theme","children":[["$","$L6",null,{}],"$undefined",["$","$L7",null,{"pageMap":[{"data":{"*":{"theme":{"copyPage":false,"footer":false,"navbar":false,"toc":false}},"install":{"title":"설치하기"},"run":{"title":"실행하기"}}},{"name":"install","route":"/install","frontMatter":{"title":"Page","filePath":"app/install/page.mdx"},"title":"설치하기"},{"name":"run","route":"/run","frontMatter":{"title":"Page","filePath":"app/run/page.mdx"},"title":"실행하기"}],"children":[["$","$L8",null,{}],["$","$L9",null,{"parallelRouterKey":"children","template":["$","$La",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]]}]]}]}]}]]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
:HL["/_next/static/chunks/13o8zmp072q8t.css","style"]
|
||||||
|
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"run","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[55169,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"TOCProvider"]
|
||||||
|
3:I[769,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"Sidebar"]
|
||||||
|
4:I[47486,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"ClientWrapper"]
|
||||||
|
5:I[63894,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"HeadingAnchor"]
|
||||||
|
6:I[97367,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"OutletBoundary"]
|
||||||
|
7:"$Sreact.suspense"
|
||||||
|
0:{"rsc":["$","$1","c",{"children":[["$","div",null,{"className":"x:mx-auto x:flex x:max-w-(--nextra-content-width)","children":["$","$L2",null,{"value":[{"value":"자율주행 알고리즘 런처","id":"자율주행-알고리즘-런처","depth":2},{"value":"주요 기능","id":"주요-기능","depth":3}],"children":[["$","$L3",null,{}],["$","$L4",null,{"metadata":{"title":"Page","filePath":"app/run/page.mdx"},"sourceCode":"## 자율주행 알고리즘 런처\n\n자율주행 알고리즘 런처는 다양한 카테고리의 알고리즘을 \n손쉽게 실행하고 관리할 수 있는 도구입니다.\n\n### 주요 기능\n\n- 여러 카테고리의 알고리즘을 한 번에 실행 및 종료\n- 특정 카테고리 알고리즘만 선택적으로 제어 가능\n- 다른 알고리즘으로 교체 실행 가능\n- 실행 환경 설정 저장 및 재사용","children":[["$","div",null,{"id":"nextra-skip-nav"}],["$","main",null,{"data-pagefind-body":true,"children":[["$","h2",null,{"id":"자율주행-알고리즘-런처","className":"x:tracking-tight x:text-slate-900 x:dark:text-slate-100 x:font-semibold x:target:animate-[fade-in_1.5s] x:mt-10 x:border-b x:pb-1 x:text-3xl nextra-border","children":["자율주행 알고리즘 런처",["$","$L5",null,{"id":"자율주행-알고리즘-런처"}]]}],"\n",["$","p",null,{"className":"x:not-first:mt-[1.25em] x:leading-7","children":["자율주행 알고리즘 런처는 다양한 카테고리의 알고리즘을",["$","br",null,{}],"\n","손쉽게 실행하고 관리할 수 있는 도구입니다."]}],"\n",["$","h3",null,{"id":"주요-기능","className":"x:tracking-tight x:text-slate-900 x:dark:text-slate-100 x:font-semibold x:target:animate-[fade-in_1.5s] x:mt-8 x:text-2xl","children":["주요 기능",["$","$L5",null,{"id":"주요-기능"}]]}],"\n",["$","ul",null,{"className":"x:[:is(ol,ul)_&]:my-[.75em] x:not-first:mt-[1.25em] x:list-disc x:ms-[1.5em]","children":["\n",["$","li",null,{"className":"x:my-[.5em]","children":"여러 카테고리의 알고리즘을 한 번에 실행 및 종료"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"특정 카테고리 알고리즘만 선택적으로 제어 가능"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"다른 알고리즘으로 교체 실행 가능"}],"\n",["$","li",null,{"className":"x:my-[.5em]","children":"실행 환경 설정 저장 및 재사용"}],"\n"]}]]}]]}]]}]}],null,["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
|
8:null
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
1:"$Sreact.fragment"
|
||||||
|
2:I[39756,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
3:I[37457,["/_next/static/chunks/0x.~j7vy9ohz0.js","/_next/static/chunks/0tsl9e86lbpo2.js","/_next/static/chunks/0d3shmwh5_nmn.js","/_next/static/chunks/07xlws-230erd.js","/_next/static/chunks/0e6i-ghtpo-w_.js"],"default"]
|
||||||
|
4:[]
|
||||||
|
0:{"rsc":["$","$1","c",{"children":[null,["$","$L2",null,{"parallelRouterKey":"children","template":["$","$L3",null,{}]}]]}],"isPartial":false,"staleTime":300,"varyParams":"$W4","buildId":"5Uz6f9AwkIdRZ33wUvlU3"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,58 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: #07111f;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.splash {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: radial-gradient(circle at 50% 35%, #11243e 0%, #07111f 54%, #03070d 100%);
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.copyright {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="splash">
|
||||||
|
<img src="./images/splash.png" draggable="false" />
|
||||||
|
<div class="copyright">© 2026 Autonomy Studio. All rights reserved.</div>
|
||||||
|
<div class="version" id="version"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const v = new URLSearchParams(window.location.search).get('version')
|
||||||
|
if (v) document.getElementById('version').textContent = 'v' + v
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,950 @@
|
|||||||
|
import { app, shell, BrowserWindow, ipcMain, protocol, net, session as electronSession, dialog } from 'electron'
|
||||||
|
import { join, dirname, resolve } from 'path'
|
||||||
|
import { chmodSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
||||||
|
import { get as httpGet } from 'http'
|
||||||
|
import { get as httpsGet } from 'https'
|
||||||
|
import { spawn, exec, ChildProcess } from 'child_process'
|
||||||
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
|
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
||||||
|
])
|
||||||
|
|
||||||
|
let splash: BrowserWindow | null = null
|
||||||
|
|
||||||
|
type CommandResult = {
|
||||||
|
ok: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TailscaleStatusResult = {
|
||||||
|
connected: boolean
|
||||||
|
ip: string
|
||||||
|
backendState: string
|
||||||
|
online?: boolean
|
||||||
|
health?: string[]
|
||||||
|
loginName?: string
|
||||||
|
userName?: string
|
||||||
|
peers?: TailscalePeerStatus[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TailscalePeerStatus = {
|
||||||
|
id: string
|
||||||
|
hostName: string
|
||||||
|
dnsName?: string
|
||||||
|
os?: string
|
||||||
|
userName?: string
|
||||||
|
loginName?: string
|
||||||
|
ip: string
|
||||||
|
ips: string[]
|
||||||
|
online: boolean
|
||||||
|
active: boolean
|
||||||
|
lastSeen?: string
|
||||||
|
relay?: string
|
||||||
|
currentAddress?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SshKeyResult = {
|
||||||
|
publicKey: string
|
||||||
|
publicKeyPath: string
|
||||||
|
privateKeyPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLauncherDir(): string {
|
||||||
|
if (process.env.APPIMAGE) return dirname(process.env.APPIMAGE)
|
||||||
|
if (is.dev) return process.cwd()
|
||||||
|
return dirname(process.execPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExecPath(execPath?: string): string | undefined {
|
||||||
|
if (!execPath) return undefined
|
||||||
|
return execPath.startsWith('./')
|
||||||
|
? join(getLauncherDir(), execPath.slice(2))
|
||||||
|
: execPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceRoot(): string {
|
||||||
|
return is.dev ? resolve(process.cwd(), '..') : getLauncherDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLauncherSshKeyPath(): string {
|
||||||
|
return join(app.getPath('home'), '.autonomy-studio', 'ssh', 'id_ed25519')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDevAppPath(devAppPath?: string): string | undefined {
|
||||||
|
if (!devAppPath) return undefined
|
||||||
|
return devAppPath.startsWith('./') || devAppPath.startsWith('../')
|
||||||
|
? resolve(getWorkspaceRoot(), devAppPath)
|
||||||
|
: devAppPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultDevAppPath(appName: string): string | undefined {
|
||||||
|
const devPathByApp: Record<string, string> = {
|
||||||
|
'ADS IDE': './apps/ads-ide/ads-ide-fe',
|
||||||
|
LMS: './apps/lms/lms-fe',
|
||||||
|
}
|
||||||
|
return resolveDevAppPath(devPathByApp[appName])
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunnableDevAppPath(devAppPath?: string): boolean {
|
||||||
|
return Boolean(devAppPath && existsSync(join(devAppPath, 'package.json')))
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDevHandoffFile(appName: string, appUrl: string, handoffToken: string, audienceApp: string): string {
|
||||||
|
const safeAppName = appName.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase()
|
||||||
|
const filePath = join(app.getPath('temp'), `autonomy-studio-${safeAppName}-handoff-${process.pid}.json`)
|
||||||
|
|
||||||
|
writeFileSync(filePath, JSON.stringify({
|
||||||
|
appUrl,
|
||||||
|
handoffToken,
|
||||||
|
audienceApp,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}))
|
||||||
|
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersionFilePath(targetPath: string): string {
|
||||||
|
return `${targetPath}.version`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInstalledVersion(targetPath?: string): string | null {
|
||||||
|
if (!targetPath) return null
|
||||||
|
const versionFilePath = getVersionFilePath(targetPath)
|
||||||
|
if (!existsSync(versionFilePath)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return readFileSync(versionFilePath, 'utf8').trim() || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMatchingFiles(dirPath: string, shouldRemove: (fileName: string) => boolean): number {
|
||||||
|
if (!existsSync(dirPath)) return 0
|
||||||
|
|
||||||
|
let removed = 0
|
||||||
|
for (const entry of readdirSync(dirPath)) {
|
||||||
|
const fullPath = join(dirPath, entry)
|
||||||
|
try {
|
||||||
|
if (statSync(fullPath).isFile() && shouldRemove(entry)) {
|
||||||
|
rmSync(fullPath, { force: true })
|
||||||
|
removed += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore files that disappear while cleanup is running.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
function countMatchingFiles(dirPath: string, shouldCount: (fileName: string) => boolean): number {
|
||||||
|
if (!existsSync(dirPath)) return 0
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
for (const entry of readdirSync(dirPath)) {
|
||||||
|
const fullPath = join(dirPath, entry)
|
||||||
|
try {
|
||||||
|
if (statSync(fullPath).isFile() && shouldCount(entry)) {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore files that disappear while cache state is being checked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLauncherCacheStatus(): Promise<{ available: boolean; files: number; rendererCacheBytes: number }> {
|
||||||
|
const tempFiles = countMatchingFiles(app.getPath('temp'), (fileName) =>
|
||||||
|
/^autonomy-studio-launcher-.+\.deb(?:\.download)?$/.test(fileName)
|
||||||
|
)
|
||||||
|
const downloadFiles = countMatchingFiles(getLauncherDir(), (fileName) => fileName.endsWith('.download'))
|
||||||
|
const rendererCacheBytes = await electronSession.defaultSession.getCacheSize()
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: tempFiles + downloadFiles > 0 || rendererCacheBytes > 0,
|
||||||
|
files: tempFiles + downloadFiles,
|
||||||
|
rendererCacheBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearLauncherCache(): Promise<{ ok: true; removedFiles: number }> {
|
||||||
|
const removedTempFiles = removeMatchingFiles(app.getPath('temp'), (fileName) =>
|
||||||
|
/^autonomy-studio-launcher-.+\.deb(?:\.download)?$/.test(fileName)
|
||||||
|
)
|
||||||
|
const removedDownloadFiles = removeMatchingFiles(getLauncherDir(), (fileName) => fileName.endsWith('.download'))
|
||||||
|
|
||||||
|
await electronSession.defaultSession.clearCache()
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
removedFiles: removedTempFiles + removedDownloadFiles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(
|
||||||
|
url: string,
|
||||||
|
targetPath: string,
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const tmpPath = `${targetPath}.download`
|
||||||
|
const client = url.startsWith('https:') ? httpsGet : httpGet
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
mkdirSync(dirname(targetPath), { recursive: true })
|
||||||
|
rmSync(tmpPath, { force: true })
|
||||||
|
|
||||||
|
const request = client(url, (response) => {
|
||||||
|
const statusCode = response.statusCode ?? 0
|
||||||
|
const redirectUrl = response.headers.location
|
||||||
|
|
||||||
|
if (statusCode >= 300 && statusCode < 400 && redirectUrl) {
|
||||||
|
response.resume()
|
||||||
|
const nextUrl = new URL(redirectUrl, url).toString()
|
||||||
|
downloadFile(nextUrl, targetPath, onProgress).then(resolve).catch(reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
|
response.resume()
|
||||||
|
reject(new Error(`Download failed with status ${statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Number(response.headers['content-length'] ?? 0)
|
||||||
|
let received = 0
|
||||||
|
const file = createWriteStream(tmpPath)
|
||||||
|
|
||||||
|
response.on('data', (chunk: Buffer) => {
|
||||||
|
received += chunk.length
|
||||||
|
if (total > 0) {
|
||||||
|
onProgress(Math.min(99, Math.round((received / total) * 100)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
response.pipe(file)
|
||||||
|
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close(() => {
|
||||||
|
renameSync(tmpPath, targetPath)
|
||||||
|
chmodSync(targetPath, 0o755)
|
||||||
|
onProgress(100)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
file.on('error', (error) => {
|
||||||
|
rmSync(tmpPath, { force: true })
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('error', (error) => {
|
||||||
|
rmSync(tmpPath, { force: true })
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDebPackage(debPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('pkexec', ['dpkg', '-i', debPath], {
|
||||||
|
stdio: 'ignore'
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Launcher update failed with exit code ${code ?? 'unknown'}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command: string, args: string[], timeoutMs = 30000, extraEnv?: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
||||||
|
})
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
reject(new Error(`${command} timed out`))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += String(chunk)
|
||||||
|
})
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr += String(chunk)
|
||||||
|
})
|
||||||
|
child.on('error', (error) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ stdout, stderr })
|
||||||
|
} else {
|
||||||
|
reject(new Error(stderr.trim() || stdout.trim() || `${command} exited with code ${code ?? 'unknown'}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
if (/request dismissed|dismissed|cancelled|canceled/i.test(message)) return '인증이 취소되었습니다'
|
||||||
|
if (/not authorized|authentication failed/i.test(message)) return '관리자 인증에 실패했습니다'
|
||||||
|
if (/No such file or directory|ENOENT/i.test(message)) return '명령을 찾을 수 없습니다'
|
||||||
|
const sanitized = message
|
||||||
|
.replace(/(?:tskey|hskey)-auth-[A-Za-z0-9_-]+/g, '[auth-key]')
|
||||||
|
.trim()
|
||||||
|
return sanitized ? `${fallback}: ${sanitized}` : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
function isTailscalePermissionError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return /set --operator|not require root|permission denied|access denied|must be root|requires root/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tailscaleCommandEnv(loginServer: string): NodeJS.ProcessEnv | undefined {
|
||||||
|
return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTailscaleCoordinationIssue(health?: string[]): boolean {
|
||||||
|
return Boolean(
|
||||||
|
health?.some((message) =>
|
||||||
|
/coordination server|synchronize the state|control server|login server|headscale/i.test(message),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommandIfPossible(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutMs = 10000,
|
||||||
|
extraEnv?: NodeJS.ProcessEnv,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await runCommand(command, args, timeoutMs, extraEnv)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLauncherSshKey(): Promise<SshKeyResult> {
|
||||||
|
const privateKeyPath = getLauncherSshKeyPath()
|
||||||
|
const publicKeyPath = `${privateKeyPath}.pub`
|
||||||
|
mkdirSync(dirname(privateKeyPath), { recursive: true, mode: 0o700 })
|
||||||
|
|
||||||
|
if (!existsSync(privateKeyPath) || !existsSync(publicKeyPath)) {
|
||||||
|
rmSync(privateKeyPath, { force: true })
|
||||||
|
rmSync(publicKeyPath, { force: true })
|
||||||
|
await runCommand('ssh-keygen', [
|
||||||
|
'-t',
|
||||||
|
'ed25519',
|
||||||
|
'-N',
|
||||||
|
'',
|
||||||
|
'-C',
|
||||||
|
'autonomy-studio-launcher',
|
||||||
|
'-f',
|
||||||
|
privateKeyPath,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
chmodSync(dirname(privateKeyPath), 0o700)
|
||||||
|
chmodSync(privateKeyPath, 0o600)
|
||||||
|
chmodSync(publicKeyPath, 0o644)
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: readFileSync(publicKeyPath, 'utf8').trim(),
|
||||||
|
publicKeyPath,
|
||||||
|
privateKeyPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
await runCommandIfPossible('tailscale', ['logout'], 10000, commandEnv)
|
||||||
|
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTailscaleSessionWithPkexec(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
const loggedOut = await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'logout'], 30000, commandEnv)
|
||||||
|
if (!loggedOut) {
|
||||||
|
await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'down'], 30000, commandEnv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
|
||||||
|
const attempts: Array<[string, string[]]> = [
|
||||||
|
['tailscale', ['status', '--json']],
|
||||||
|
['tailscale', ['status']],
|
||||||
|
]
|
||||||
|
|
||||||
|
let lastError = ''
|
||||||
|
for (const [command, args] of attempts) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await runCommand(command, args)
|
||||||
|
if (args.includes('--json')) {
|
||||||
|
const status = JSON.parse(stdout) as {
|
||||||
|
BackendState?: string
|
||||||
|
Self?: { TailscaleIPs?: string[]; UserID?: number; LoginName?: string; Online?: boolean }
|
||||||
|
Peer?: Record<string, {
|
||||||
|
ID?: string
|
||||||
|
HostName?: string
|
||||||
|
DNSName?: string
|
||||||
|
OS?: string
|
||||||
|
UserID?: number
|
||||||
|
TailscaleIPs?: string[]
|
||||||
|
Online?: boolean
|
||||||
|
Active?: boolean
|
||||||
|
LastSeen?: string
|
||||||
|
Relay?: string
|
||||||
|
CurAddr?: string
|
||||||
|
}>
|
||||||
|
Health?: string[]
|
||||||
|
User?: Record<string, { LoginName?: string; ProfilePicURL?: string; DisplayName?: string }>
|
||||||
|
}
|
||||||
|
const selfUser = status.Self?.UserID != null ? status.User?.[String(status.Self.UserID)] : undefined
|
||||||
|
const health = Array.isArray(status.Health) ? status.Health : []
|
||||||
|
const online = status.Self?.Online === true
|
||||||
|
const connected = status.BackendState === 'Running' && online && !hasTailscaleCoordinationIssue(health)
|
||||||
|
const peers = Object.entries(status.Peer ?? {}).map(([key, peer]) => {
|
||||||
|
const user = peer.UserID != null ? status.User?.[String(peer.UserID)] : undefined
|
||||||
|
const ips = Array.isArray(peer.TailscaleIPs) ? peer.TailscaleIPs : []
|
||||||
|
return {
|
||||||
|
id: peer.ID ?? key,
|
||||||
|
hostName: peer.HostName ?? peer.DNSName ?? key,
|
||||||
|
dnsName: peer.DNSName,
|
||||||
|
os: peer.OS,
|
||||||
|
userName: user?.DisplayName,
|
||||||
|
loginName: user?.LoginName,
|
||||||
|
ip: ips[0] ?? '',
|
||||||
|
ips,
|
||||||
|
online: peer.Online === true,
|
||||||
|
active: peer.Active === true,
|
||||||
|
lastSeen: peer.LastSeen,
|
||||||
|
relay: peer.Relay,
|
||||||
|
currentAddress: peer.CurAddr,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
ip: status.Self?.TailscaleIPs?.[0] ?? '',
|
||||||
|
backendState: status.BackendState ?? 'Unknown',
|
||||||
|
online,
|
||||||
|
health,
|
||||||
|
loginName: status.Self?.LoginName ?? selfUser?.LoginName ?? '',
|
||||||
|
userName: selfUser?.DisplayName ?? '',
|
||||||
|
peers,
|
||||||
|
error: connected ? undefined : health[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = stdout.trim()
|
||||||
|
const connected = Boolean(output) && !/logged out|not logged in/i.test(output)
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
ip: output.match(/\b100\.\d+\.\d+\.\d+\b/)?.[0] ?? '',
|
||||||
|
backendState: connected ? 'Running' : 'Stopped',
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
ip: '',
|
||||||
|
backendState: 'Unavailable',
|
||||||
|
error: lastError || 'tailscale status failed',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginTailscale(
|
||||||
|
authKey: string,
|
||||||
|
loginServer: string,
|
||||||
|
hostname?: string,
|
||||||
|
forceReset = false,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
const tailscaleArgs = ['up']
|
||||||
|
const trimmedAuthKey = authKey.trim()
|
||||||
|
const trimmedLoginServer = loginServer.trim()
|
||||||
|
const trimmedHostname = hostname?.trim() ?? ''
|
||||||
|
const commandEnv = tailscaleCommandEnv(trimmedLoginServer)
|
||||||
|
|
||||||
|
if (forceReset) {
|
||||||
|
await resetTailscaleSession(commandEnv)
|
||||||
|
tailscaleArgs.push('--reset')
|
||||||
|
}
|
||||||
|
if (trimmedLoginServer) {
|
||||||
|
tailscaleArgs.push('--login-server', trimmedLoginServer)
|
||||||
|
}
|
||||||
|
if (trimmedAuthKey) {
|
||||||
|
tailscaleArgs.push('--auth-key', trimmedAuthKey)
|
||||||
|
}
|
||||||
|
if (trimmedHostname) {
|
||||||
|
tailscaleArgs.push('--hostname', trimmedHostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runCommand('tailscale', tailscaleArgs, 30000, commandEnv)
|
||||||
|
await waitForTailscaleConnected()
|
||||||
|
return { ok: true, message: 'Tailscale 연결 완료' }
|
||||||
|
} catch (error) {
|
||||||
|
if (isTailscalePermissionError(error)) {
|
||||||
|
try {
|
||||||
|
if (forceReset) {
|
||||||
|
await resetTailscaleSessionWithPkexec(commandEnv)
|
||||||
|
}
|
||||||
|
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
|
||||||
|
await waitForTailscaleConnected()
|
||||||
|
return { ok: true, message: 'Tailscale 연결 완료' }
|
||||||
|
} catch (pkexecError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다.')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: commandErrorMessage(error, 'Tailscale 연결 실패'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTailscaleConnected(): Promise<void> {
|
||||||
|
let lastStatus: TailscaleStatusResult | null = null
|
||||||
|
for (let i = 0; i < 30; i += 1) {
|
||||||
|
await delay(500)
|
||||||
|
const status = await readTailscaleStatus()
|
||||||
|
lastStatus = status
|
||||||
|
if (status.connected) return
|
||||||
|
}
|
||||||
|
throw new Error(lastStatus?.error || `Tailscale state is ${lastStatus?.backendState ?? 'not connected'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutTailscale(): Promise<CommandResult> {
|
||||||
|
try {
|
||||||
|
await runCommand('tailscale', ['logout'])
|
||||||
|
return { ok: true, message: 'Tailscale 로그아웃 완료' }
|
||||||
|
} catch (error) {
|
||||||
|
if (isTailscalePermissionError(error)) {
|
||||||
|
try {
|
||||||
|
await runCommand('tailscale', ['down'])
|
||||||
|
return { ok: true, message: 'Tailscale 연결 해제 완료' }
|
||||||
|
} catch (downError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: commandErrorMessage(downError, commandErrorMessage(error, 'Tailscale 로그아웃 권한이 없습니다.')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: commandErrorMessage(error, 'Tailscale 로그아웃 실패'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSplashWindow(): void {
|
||||||
|
splash = new BrowserWindow({
|
||||||
|
width: 580,
|
||||||
|
height: 360,
|
||||||
|
show: true,
|
||||||
|
frame: false,
|
||||||
|
resizable: false,
|
||||||
|
center: true,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
backgroundColor: '#07111f'
|
||||||
|
})
|
||||||
|
const splashPath = is.dev
|
||||||
|
? join(process.cwd(), 'resources/splash.html')
|
||||||
|
: join(__dirname, '../renderer/splash.html')
|
||||||
|
splash.loadFile(splashPath, { query: { version: app.getVersion() } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachDevToolsShortcut(window: BrowserWindow): void {
|
||||||
|
window.webContents.on('before-input-event', (event, input) => {
|
||||||
|
const key = input.key.toLowerCase()
|
||||||
|
if (input.key === 'F12' || (input.control && input.shift && key === 'i')) {
|
||||||
|
event.preventDefault()
|
||||||
|
window.webContents.toggleDevTools()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(): void {
|
||||||
|
const appWindows = new Map<string, BrowserWindow>()
|
||||||
|
const appProcesses = new Map<string, ChildProcess>()
|
||||||
|
|
||||||
|
const mainWindow = new BrowserWindow({
|
||||||
|
width: 1366,
|
||||||
|
height: 860,
|
||||||
|
icon: join(process.cwd(), 'resources/icon.png'),
|
||||||
|
show: false,
|
||||||
|
frame: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
attachDevToolsShortcut(mainWindow)
|
||||||
|
|
||||||
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
splash?.close()
|
||||||
|
splash = null
|
||||||
|
mainWindow.show()
|
||||||
|
}, 1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
|
shell.openExternal(details.url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('window-minimize', (event) => {
|
||||||
|
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||||
|
})
|
||||||
|
ipcMain.on('window-maximize', (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!win) return
|
||||||
|
win.isMaximized() ? win.unmaximize() : win.maximize()
|
||||||
|
})
|
||||||
|
ipcMain.on('window-close', (event) => {
|
||||||
|
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('get-app-installation', (_, payload: {
|
||||||
|
appName?: string
|
||||||
|
execPath?: string
|
||||||
|
devAppPath?: string
|
||||||
|
}) => {
|
||||||
|
const devAppPath = resolveDevAppPath(payload.devAppPath) ?? (payload.appName ? getDefaultDevAppPath(payload.appName) : undefined)
|
||||||
|
if (is.dev && devAppPath) {
|
||||||
|
const packagePath = join(devAppPath, 'package.json')
|
||||||
|
let version: string | null = null
|
||||||
|
try {
|
||||||
|
version = JSON.parse(readFileSync(packagePath, 'utf8')).version ?? null
|
||||||
|
} catch {
|
||||||
|
version = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: existsSync(packagePath),
|
||||||
|
version,
|
||||||
|
path: devAppPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = resolveExecPath(payload.execPath)
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: targetPath ? existsSync(targetPath) : false,
|
||||||
|
version: readInstalledVersion(targetPath),
|
||||||
|
path: targetPath ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('download-app', async (event, payload: {
|
||||||
|
appName: string
|
||||||
|
downloadUrl: string
|
||||||
|
execPath?: string
|
||||||
|
latestVersion?: string
|
||||||
|
}) => {
|
||||||
|
const targetPath = resolveExecPath(payload.execPath)
|
||||||
|
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
|
||||||
|
|
||||||
|
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
|
||||||
|
event.sender.send('app-download-progress', {
|
||||||
|
appName: payload.appName,
|
||||||
|
progress,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (payload.latestVersion) {
|
||||||
|
writeFileSync(getVersionFilePath(targetPath), `${payload.latestVersion}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
version: payload.latestVersion ?? readInstalledVersion(targetPath),
|
||||||
|
path: targetPath,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('install-launcher-update', async (event, payload: {
|
||||||
|
downloadUrl: string
|
||||||
|
latestVersion?: string
|
||||||
|
}) => {
|
||||||
|
const version = payload.latestVersion?.replace(/[^0-9A-Za-z._-]/g, '') || String(Date.now())
|
||||||
|
const debPath = join(app.getPath('temp'), `autonomy-studio-launcher-${version}.deb`)
|
||||||
|
|
||||||
|
await downloadFile(payload.downloadUrl, debPath, (progress) => {
|
||||||
|
event.sender.send('app-download-progress', {
|
||||||
|
appName: 'launcher',
|
||||||
|
progress,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await installDebPackage(debPath)
|
||||||
|
|
||||||
|
if (!is.dev) {
|
||||||
|
app.relaunch()
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: true,
|
||||||
|
version: payload.latestVersion ?? null,
|
||||||
|
path: debPath,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus())
|
||||||
|
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
|
||||||
|
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
||||||
|
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
||||||
|
ipcMain.handle(
|
||||||
|
'launcher-tailscale-login',
|
||||||
|
(_event, authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
|
||||||
|
loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)),
|
||||||
|
)
|
||||||
|
ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale())
|
||||||
|
|
||||||
|
const onAppClosed = (appName: string): void => {
|
||||||
|
mainWindow.webContents.send('app-window-closed', appName)
|
||||||
|
if (appWindows.size === 0 && appProcesses.size === 0) {
|
||||||
|
mainWindow.restore()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.on('launch-app', (_, payload: {
|
||||||
|
appName: string
|
||||||
|
url: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
devAppPath?: string
|
||||||
|
execPath?: string
|
||||||
|
handoffCode?: string
|
||||||
|
audienceApp?: string
|
||||||
|
}) => {
|
||||||
|
const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload
|
||||||
|
const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined
|
||||||
|
const authLaunchArgs = handoffCode
|
||||||
|
? [
|
||||||
|
`--handoff-token=${encodeURIComponent(handoffCode)}`,
|
||||||
|
`--audience-app=${encodeURIComponent(audienceApp)}`
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
// 이미 실행 중이면 포커스
|
||||||
|
const existingWin = appWindows.get(appName)
|
||||||
|
if (existingWin && !existingWin.isDestroyed()) {
|
||||||
|
if (authProtocolUrl) shell.openExternal(authProtocolUrl)
|
||||||
|
existingWin.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existingProc = appProcesses.get(appName)
|
||||||
|
if (existingProc) {
|
||||||
|
if (is.dev && handoffCode && audienceApp !== 'ADS IDE') {
|
||||||
|
existingProc.kill()
|
||||||
|
appProcesses.delete(appName)
|
||||||
|
} else {
|
||||||
|
if (authProtocolUrl) shell.openExternal(authProtocolUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.minimize()
|
||||||
|
|
||||||
|
// ── 별도 Electron 프로세스로 실행 ──
|
||||||
|
const resolvedExecPath = resolveExecPath(execPath)
|
||||||
|
const resolvedDevAppPath = resolveDevAppPath(devAppPath) ?? getDefaultDevAppPath(appName)
|
||||||
|
if (is.dev && resolvedDevAppPath && !isRunnableDevAppPath(resolvedDevAppPath)) {
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'개발 앱 실행 실패',
|
||||||
|
`${appName} 개발 앱 경로를 찾을 수 없습니다.\n${resolvedDevAppPath}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnPath = is.dev && resolvedDevAppPath && isRunnableDevAppPath(resolvedDevAppPath)
|
||||||
|
? resolvedDevAppPath
|
||||||
|
: resolvedExecPath && existsSync(resolvedExecPath)
|
||||||
|
? resolvedExecPath
|
||||||
|
: resolvedExecPath
|
||||||
|
if (spawnPath) {
|
||||||
|
const useDevLauncher = is.dev && spawnPath === resolvedDevAppPath
|
||||||
|
const args = useDevLauncher
|
||||||
|
? ['run', 'dev']
|
||||||
|
: [`--url=${encodeURIComponent(url)}`, ...authLaunchArgs, ...(authProtocolUrl ? [authProtocolUrl] : [])]
|
||||||
|
const execBin = useDevLauncher ? 'npm' : spawnPath
|
||||||
|
|
||||||
|
const childEnv = { ...process.env }
|
||||||
|
if (useDevLauncher) {
|
||||||
|
childEnv.ATNMY_APP_URL = url
|
||||||
|
}
|
||||||
|
if (useDevLauncher && handoffCode) {
|
||||||
|
childEnv.ATNMY_HANDOFF_FILE = writeDevHandoffFile(appName, url, handoffCode, audienceApp)
|
||||||
|
childEnv.ATNMY_HANDOFF_TOKEN = handoffCode
|
||||||
|
childEnv.ATNMY_AUDIENCE_APP = audienceApp
|
||||||
|
} else if (handoffCode) {
|
||||||
|
childEnv.ATNMY_HANDOFF_TOKEN = handoffCode
|
||||||
|
childEnv.ATNMY_AUDIENCE_APP = audienceApp
|
||||||
|
}
|
||||||
|
|
||||||
|
delete childEnv.ELECTRON_RENDERER_URL
|
||||||
|
delete childEnv.VITE_DEV_SERVER_URL
|
||||||
|
|
||||||
|
const logPath = useDevLauncher ? join(app.getPath('temp'), `autonomy-studio-${appName.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase()}.dev.log`) : undefined
|
||||||
|
const logFd = logPath ? openSync(logPath, 'a') : undefined
|
||||||
|
if (logPath) {
|
||||||
|
writeFileSync(logPath, [
|
||||||
|
`\n[${new Date().toISOString()}] ${execBin} ${args.join(' ')}`,
|
||||||
|
`[launcher] app=${appName} useDevLauncher=${useDevLauncher ? 'true' : 'false'} hasHandoff=${handoffCode ? 'true' : 'false'} audience=${audienceApp}`,
|
||||||
|
`[launcher] handoffFile=${childEnv.ATNMY_HANDOFF_FILE ?? ''} hasHandoffTokenEnv=${childEnv.ATNMY_HANDOFF_TOKEN ? 'true' : 'false'}`,
|
||||||
|
''
|
||||||
|
].join('\n'), { flag: 'a' })
|
||||||
|
}
|
||||||
|
const child = spawn(execBin, args, {
|
||||||
|
detached: true,
|
||||||
|
stdio: useDevLauncher && logFd !== undefined ? ['ignore', logFd, logFd] : 'ignore',
|
||||||
|
cwd: useDevLauncher ? spawnPath : undefined,
|
||||||
|
env: childEnv,
|
||||||
|
shell: useDevLauncher && process.platform === 'win32'
|
||||||
|
})
|
||||||
|
child.unref()
|
||||||
|
appProcesses.set(appName, child)
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
appProcesses.delete(appName)
|
||||||
|
dialog.showErrorBox(
|
||||||
|
`${appName} 실행 실패`,
|
||||||
|
`${error instanceof Error ? error.message : '앱 실행에 실패했습니다.'}${logPath ? `\n\n로그: ${logPath}` : ''}`
|
||||||
|
)
|
||||||
|
onAppClosed(appName)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on('close', () => {
|
||||||
|
appProcesses.delete(appName)
|
||||||
|
onAppClosed(appName)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fallback: 같은 프로세스 내 BrowserWindow ──
|
||||||
|
const appWin = new BrowserWindow({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
frame: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
webSecurity: false,
|
||||||
|
additionalArguments: [
|
||||||
|
'--app-window',
|
||||||
|
`--app-name=${encodeURIComponent(appName)}`,
|
||||||
|
`--app-url=${encodeURIComponent(url)}`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appWindows.set(appName, appWin)
|
||||||
|
attachDevToolsShortcut(appWin)
|
||||||
|
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
appWin.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
appWin.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
appWin.on('closed', () => {
|
||||||
|
appWindows.delete(appName)
|
||||||
|
onAppClosed(appName)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('focus-app-window', (_, appName: string) => {
|
||||||
|
const win = appWindows.get(appName)
|
||||||
|
if (win && !win.isDestroyed()) { win.focus(); return }
|
||||||
|
|
||||||
|
const child = appProcesses.get(appName)
|
||||||
|
if (!child || child.pid === undefined) return
|
||||||
|
const pid = child.pid
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
exec(`xdotool search --name "${appName}" windowactivate --sync`)
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
exec(`osascript -e 'tell application "System Events" to set frontmost of (first process whose unix id is ${pid}) to true'`)
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
exec(`powershell -command "(New-Object -ComObject wscript.shell).AppActivate(${pid})"`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on('close-app-window', (_, appName: string) => {
|
||||||
|
const win = appWindows.get(appName)
|
||||||
|
if (win && !win.isDestroyed()) { win.close(); return }
|
||||||
|
|
||||||
|
const child = appProcesses.get(appName)
|
||||||
|
if (child) child.kill()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
electronApp.setAppUserModelId('com.electron')
|
||||||
|
|
||||||
|
protocol.handle('appdocs', (request) => {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const docsBase = is.dev
|
||||||
|
? join(process.cwd(), 'resources/docs')
|
||||||
|
: join(__dirname, '../renderer/docs')
|
||||||
|
const filePath = join(docsBase, url.pathname)
|
||||||
|
return net.fetch(`file://${filePath}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('browser-window-created', (_, window) => {
|
||||||
|
optimizer.watchWindowShortcuts(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
createSplashWindow()
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
app.on('activate', function () {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
|
|
||||||
|
const argv = process.argv
|
||||||
|
const isAppWindow = argv.includes('--app-window')
|
||||||
|
const appName = decodeURIComponent(
|
||||||
|
argv.find((a) => a.startsWith('--app-name='))?.slice('--app-name='.length) ?? ''
|
||||||
|
)
|
||||||
|
const appUrl = decodeURIComponent(
|
||||||
|
argv.find((a) => a.startsWith('--app-url='))?.slice('--app-url='.length) ?? ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const appWindowMeta = { isAppWindow, appName, url: appUrl }
|
||||||
|
const launcherTailscale = {
|
||||||
|
status: () => ipcRenderer.invoke('launcher-tailscale-status'),
|
||||||
|
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
|
||||||
|
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
|
||||||
|
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
|
||||||
|
}
|
||||||
|
const launcherSshKey = {
|
||||||
|
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.contextIsolated) {
|
||||||
|
try {
|
||||||
|
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||||
|
contextBridge.exposeInMainWorld('appWindowMeta', appWindowMeta)
|
||||||
|
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
|
||||||
|
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
window.electron = electronAPI
|
||||||
|
// @ts-ignore
|
||||||
|
window.appWindowMeta = appWindowMeta
|
||||||
|
// @ts-ignore
|
||||||
|
window.launcherTailscale = launcherTailscale
|
||||||
|
// @ts-ignore
|
||||||
|
window.launcherSshKey = launcherSshKey
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Autonomy Studio</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/gh/moonspam/NanumSquare@1.0/nanumsquare.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import AppWindowShell from './pages/AppWindowShell'
|
||||||
|
import type { AuthSession } from './pages/LoginForm'
|
||||||
|
|
||||||
|
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||||
|
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||||
|
|
||||||
|
function App(): React.JSX.Element {
|
||||||
|
const [authSession, setAuthSession] = useState<AuthSession | null>(() => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(AUTH_SESSION_KEY) ?? window.sessionStorage.getItem(AUTH_SESSION_KEY)
|
||||||
|
return raw ? JSON.parse(raw) as AuthSession : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const meta = window.appWindowMeta
|
||||||
|
if (meta?.isAppWindow) {
|
||||||
|
return <AppWindowShell appName={meta.appName} url={meta.url} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionUpdate = useCallback((session: AuthSession): void => {
|
||||||
|
setAuthSession(session)
|
||||||
|
if (window.localStorage.getItem(AUTH_SESSION_KEY)) {
|
||||||
|
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||||
|
} else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) {
|
||||||
|
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLogout = useCallback((): void => {
|
||||||
|
window.launcherTailscale?.logout().catch(() => undefined)
|
||||||
|
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
|
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
|
setAuthSession(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authSession?.accessToken) return
|
||||||
|
|
||||||
|
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then((response) => response.ok ? response.json() as Promise<AuthSession> : null)
|
||||||
|
.then((session) => {
|
||||||
|
if (session?.accessToken) {
|
||||||
|
handleSessionUpdate(session)
|
||||||
|
} else {
|
||||||
|
handleLogout()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
handleLogout()
|
||||||
|
})
|
||||||
|
}, refreshDelayMs)
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [authSession, handleLogout, handleSessionUpdate])
|
||||||
|
|
||||||
|
return authSession
|
||||||
|
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} />
|
||||||
|
: <Login onLogin={setAuthSession} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,933 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* @font-face {
|
||||||
|
font-family: 'NanumGothic';
|
||||||
|
src: url('./fonts/NanumGothicLight.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'NanumGothic';
|
||||||
|
src: url('./fonts/NanumGothic.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'NanumGothic';
|
||||||
|
src: url('./fonts/NanumGothicBold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'NanumGothic';
|
||||||
|
src: url('./fonts/NanumGothicExtraBold.woff2') format('woff2');
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
} */
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-deep: #0a0b0e;
|
||||||
|
--bg-sidebar: #0d0f14;
|
||||||
|
--bg-main: #0f1117;
|
||||||
|
--border: rgba(255, 255, 255, 0.06);
|
||||||
|
--accent: #3b7ffa;
|
||||||
|
--accent-glow: rgba(59, 127, 250, 0.35);
|
||||||
|
--text-primary: #e8eaf0;
|
||||||
|
--text-secondary: rgba(220, 225, 238, 0.76);
|
||||||
|
--text-muted: rgba(157, 163, 175, 0.911);
|
||||||
|
--text-soft: #d6dae3;
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #000;
|
||||||
|
font-family: 'NanumSquare', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ── */
|
||||||
|
.sidebar {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.sidebar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgba(59, 127, 250, 0.15) 40%,
|
||||||
|
rgba(59, 127, 250, 0.15) 60%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 13px 24px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
.nav-item-unavailable {
|
||||||
|
color: rgba(200, 205, 220, 0.42);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.nav-item-unavailable .nav-item-icon {
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
.nav-item-unavailable:hover {
|
||||||
|
color: rgba(200, 205, 220, 0.5);
|
||||||
|
background: rgba(255, 255, 255, 0.015);
|
||||||
|
}
|
||||||
|
.nav-item-unavailable:hover .nav-item-icon {
|
||||||
|
opacity: 0.46;
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(59, 127, 250, 0.07);
|
||||||
|
}
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
box-shadow: 0 0 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.nav-item-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
margin-right: 10px;
|
||||||
|
opacity: 0.68;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.nav-item:hover .nav-item-icon {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.nav-item.active .nav-item-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.nav-item-unavailable.active {
|
||||||
|
color: rgba(200, 205, 220, 0.54);
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
.nav-item-unavailable.active::before {
|
||||||
|
background: rgba(200, 205, 220, 0.24);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.nav-item-unavailable.active .nav-item-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.nav-item-collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar Collapse Toggle Button ── */
|
||||||
|
.sidebar-collapse-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: -10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 50;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.2s, background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.sidebar-collapse-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-color: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Content ── */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-main);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.main-content::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 65% 50%, rgba(20, 25, 40, 0.9) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 50% at 20% 50%, rgba(15, 30, 60, 0.3) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Update Button ── */
|
||||||
|
.update-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
|
||||||
|
}
|
||||||
|
.update-btn:hover {
|
||||||
|
background: #5592ff;
|
||||||
|
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.update-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.update-btn:disabled {
|
||||||
|
background: rgba(59, 127, 250, 0.14);
|
||||||
|
border-color: rgba(59, 127, 250, 0.24);
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.update-btn:disabled:hover {
|
||||||
|
background: rgba(59, 127, 250, 0.14);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Updating Button (in-progress state) ── */
|
||||||
|
.updating-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: rgba(59, 127, 250, 0.15);
|
||||||
|
border: 1px solid rgba(59, 127, 250, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Update Progress Bar ── */
|
||||||
|
.update-progress-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.update-progress-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 99px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.update-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 99px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
box-shadow: 0 0 6px rgba(59,127,250,0.6);
|
||||||
|
}
|
||||||
|
.update-progress-pct {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Spinner ── */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Launch Button ── */
|
||||||
|
.launch-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
|
||||||
|
}
|
||||||
|
.launch-btn:hover {
|
||||||
|
background: #5592ff;
|
||||||
|
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.launch-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.launch-btn:disabled {
|
||||||
|
background: rgba(59, 127, 250, 0.14);
|
||||||
|
border-color: rgba(59, 127, 250, 0.24);
|
||||||
|
color: rgba(255, 255, 255, 0.38);
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.launch-btn:disabled:hover {
|
||||||
|
background: rgba(59, 127, 250, 0.14);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-pending-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px 16px;
|
||||||
|
min-width: 78px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Car Video ── */
|
||||||
|
.car-video {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center bottom;
|
||||||
|
opacity: 0;
|
||||||
|
animation: carFadeIn 1.4s 0.5s ease forwards;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-video-shadow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, var(--bg-main) 0%, var(--bg-main) 14%, rgba(15, 17, 23, 0.45) 32%, transparent 52%),
|
||||||
|
linear-gradient(to left, var(--bg-main) 0%, rgba(15, 17, 23, 0.6) 8%, transparent 35%),
|
||||||
|
linear-gradient(to top, rgba(5, 7, 12, 0.78) 0%, transparent 42%),
|
||||||
|
radial-gradient(ellipse 60% 60% at 100% 100%, rgba(5, 7, 12, 0.56) 0%, transparent 14%),
|
||||||
|
radial-gradient(ellipse 100% 100% at 80% 40%, transparent 40%, rgba(5, 7, 12, 0.5) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floor-reflection {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 120px;
|
||||||
|
background: linear-gradient(to top, rgba(8, 10, 18, 0.8) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text Content ── */
|
||||||
|
.text-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
max-width: 480px;
|
||||||
|
animation: textReveal 1s 0.2s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 52px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 60px rgba(59, 127, 250, 0.1);
|
||||||
|
}
|
||||||
|
.brand-title span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 140px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(to right, var(--accent), transparent);
|
||||||
|
margin-bottom: 22px;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 10px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: rgba(200, 210, 230, 0.85);
|
||||||
|
line-height: 2.0;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Verify Code Button ── */
|
||||||
|
.verify-code-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(59, 127, 250, 0.35);
|
||||||
|
background: rgba(59, 127, 250, 0.10);
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
}
|
||||||
|
.verify-code-btn:hover:not(:disabled) {
|
||||||
|
background: rgba(59, 127, 250, 0.2);
|
||||||
|
border-color: rgba(59, 127, 250, 0.6);
|
||||||
|
box-shadow: 0 0 12px rgba(59, 127, 250, 0.25);
|
||||||
|
}
|
||||||
|
.verify-code-btn:disabled {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: rgba(255, 255, 255, 0.07);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notice Icon Button ── */
|
||||||
|
.notice-icon-btn {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.notice-icon-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
right: 7px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
right: 0;
|
||||||
|
width: 320px;
|
||||||
|
background: rgba(13, 15, 22, 0.96);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-item-border {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notice Panel ── */
|
||||||
|
.notice-panel {
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(13, 15, 20, 0.75);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 10px;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent-glow);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-date {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-body {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-link-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-link-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard ── */
|
||||||
|
.car-bg-radial {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse 90% 70% at 60% 55%, rgba(25, 35, 70, 0.15) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-version-wrap {
|
||||||
|
position: absolute;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-version-wrap {
|
||||||
|
position: absolute;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-update-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-update-text:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: #6fa4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-update-text:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login ── */
|
||||||
|
.login-video {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center bottom;
|
||||||
|
opacity: 0;
|
||||||
|
animation: carFadeIn 1.4s 0.3s ease forwards;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-video-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(to bottom, var(--bg-deep) 0%, rgba(10, 11, 14, 0.5) 35%, transparent 60%),
|
||||||
|
linear-gradient(to top, var(--bg-deep) 0%, transparent 40%),
|
||||||
|
linear-gradient(to right, rgba(10, 11, 14, 0.3) 0%, transparent 60%),
|
||||||
|
linear-gradient(to left, rgba(10, 11, 14, 0.5) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(10, 11, 14, 0.7) 0%, transparent 70%);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
background: rgba(13, 15, 20, 0.6);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel-edge {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: linear-gradient(to bottom, transparent, rgba(59, 127, 250, 0.2) 30%, rgba(59, 127, 250, 0.2) 70%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 200px;
|
||||||
|
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(59, 127, 250, 0.06) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-reveal {
|
||||||
|
animation: textReveal 0.8s 0.2s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-reveal-late {
|
||||||
|
animation: textReveal 0.8s 0.3s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login inputs ── */
|
||||||
|
.login-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input-field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 16px 11px 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.login-input-field::placeholder { color: var(--text-muted); }
|
||||||
|
.login-input-field:focus {
|
||||||
|
border-color: rgba(59, 127, 250, 0.5);
|
||||||
|
background: rgba(59, 127, 250, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-password-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
caret-color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-password-input:focus {
|
||||||
|
border-color: rgba(59, 127, 250, 0.5);
|
||||||
|
background: rgba(59, 127, 250, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input-eye {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.login-input-eye:hover { color: var(--text-secondary); }
|
||||||
|
.login-input-eye svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
.login-checkbox {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-google-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.login-google-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login ── */
|
||||||
|
.login-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.login-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.login-input:focus {
|
||||||
|
border-color: rgba(59, 127, 250, 0.5);
|
||||||
|
background: rgba(59, 127, 250, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 11px 0;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'NanumGothic', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.3);
|
||||||
|
}
|
||||||
|
.login-btn:hover:not(:disabled) {
|
||||||
|
background: #5592ff;
|
||||||
|
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.5);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.login-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typing Cursor ── */
|
||||||
|
.typing-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1em;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 1px;
|
||||||
|
animation: cursorBlink 0.9s step-end infinite;
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursorBlink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Running State ── */
|
||||||
|
.running-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px 6px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(34, 197, 94, 0.07);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.running-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5);
|
||||||
|
animation: running-pulse 1.8s ease infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes running-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
|
||||||
|
55% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
@keyframes textReveal {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes carFadeIn {
|
||||||
|
from { opacity: 0; transform: translateX(30px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export const EnvelopeIcon = (): React.JSX.Element => (
|
||||||
|
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||||
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const LockIcon = (): React.JSX.Element => (
|
||||||
|
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PersonIcon = (): React.JSX.Element => (
|
||||||
|
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const EyeToggle = ({ show, onClick }: { show: boolean; onClick: () => void }): React.JSX.Element => (
|
||||||
|
<button type="button" onClick={onClick} className="login-input-eye">
|
||||||
|
{show ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||||
|
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const GoogleIcon = (): React.JSX.Element => (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||||
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import icon from '../assets/icon.png'
|
||||||
|
import { Minus, Square, X } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface TitleBarProps {
|
||||||
|
appName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleBar({ appName }: TitleBarProps): React.JSX.Element {
|
||||||
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||||
|
const handleMinimize = (): void => window.electron.ipcRenderer.send('window-minimize')
|
||||||
|
const handleMaximize = (): void => window.electron.ipcRenderer.send('window-maximize')
|
||||||
|
const handleClose = (): void => setShowCloseConfirm(true)
|
||||||
|
const handleConfirmClose = (): void => window.electron.ipcRenderer.send('window-close')
|
||||||
|
|
||||||
|
const winBtn =
|
||||||
|
'flex h-[20px] w-[20px] items-center justify-center text-white/50 hover:text-white transition cursor-pointer'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex h-[38px] shrink-0 items-center justify-between border-b border-white/10 bg-[#111520] px-4 pl-5 select-none"
|
||||||
|
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{/* 좌측 */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src={icon}
|
||||||
|
alt="logo"
|
||||||
|
className="mr-2 h-[18px] w-auto -translate-y-[2px] opacity-90"
|
||||||
|
/>
|
||||||
|
<span className="font-['Rajdhani',sans-serif] text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">
|
||||||
|
{appName ?? 'Autonomy Studio'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<button onClick={handleMinimize} className={winBtn}>
|
||||||
|
<Minus size={12} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={handleMaximize} className={winBtn}>
|
||||||
|
<Square size={11} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className={`${winBtn} hover:text-red-400`}
|
||||||
|
>
|
||||||
|
<X size={12} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCloseConfirm && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]"
|
||||||
|
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="relative w-[320px] overflow-hidden rounded-lg border border-[rgba(255,255,255,0.18)] bg-[rgba(22,27,39,0.72)] shadow-[0_28px_90px_rgba(0,0,0,0.58),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-2xl">
|
||||||
|
<header className="flex h-[54px] items-center justify-between px-5">
|
||||||
|
<h2 className="text-[15px] font-bold text-[var(--text-primary)]">Autonomy Studio</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCloseConfirm(false)}
|
||||||
|
className="grid h-8 w-8 place-items-center rounded-md text-white/55 hover:bg-white/[0.06] hover:text-white cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="px-5 py-5">
|
||||||
|
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
|
||||||
|
앱을 종료하시겠습니까?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<footer className="flex h-[58px] items-center justify-end gap-2 px-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCloseConfirm(false)}
|
||||||
|
className="rounded-md border border-[rgba(255,255,255,0.12)] px-5 py-2 text-[12px] font-semibold text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmClose}
|
||||||
|
className="rounded-md border border-red-400/35 bg-red-500/15 px-5 py-2 text-[12px] font-bold text-red-100 hover:border-red-400/50 hover:bg-red-500/24 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
종료
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TitleBar
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type TooltipProps = {
|
||||||
|
label: string
|
||||||
|
children: ReactNode
|
||||||
|
side?: 'top' | 'bottom'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ label, children, side = 'bottom' }: TooltipProps): React.JSX.Element {
|
||||||
|
const sideClass = side === 'top'
|
||||||
|
? 'bottom-[calc(100%+8px)]'
|
||||||
|
: 'top-[calc(100%+8px)]'
|
||||||
|
const arrowClass = side === 'top'
|
||||||
|
? 'bottom-[-4px] border-b border-r'
|
||||||
|
: 'top-[-4px] border-l border-t'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="group/tooltip relative inline-flex">
|
||||||
|
{children}
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className={`pointer-events-none absolute right-0 ${sideClass} z-[9999] whitespace-nowrap rounded-md border border-[rgba(255,255,255,0.1)] bg-[#111827] px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white/85 opacity-0 shadow-[0_14px_40px_rgba(0,0,0,0.35)] transition duration-150 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className={`absolute right-3 h-2 w-2 rotate-45 border-[rgba(255,255,255,0.1)] bg-[#111827] ${arrowClass}`} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tooltip
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export interface LauncherApp {
|
||||||
|
appKey: string
|
||||||
|
displayName: string
|
||||||
|
description: string
|
||||||
|
subtitle?: string | null
|
||||||
|
detailUrl?: string | null
|
||||||
|
videoPath?: string | null
|
||||||
|
iconKey: string
|
||||||
|
launchUrl?: string | null
|
||||||
|
devAppPath?: string | null
|
||||||
|
execPath?: string | null
|
||||||
|
sortOrder?: number
|
||||||
|
canView?: boolean
|
||||||
|
canLaunch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FALLBACK_APPS: LauncherApp[] = [
|
||||||
|
{
|
||||||
|
appKey: 'ADS IDE',
|
||||||
|
displayName: 'ADS IDE',
|
||||||
|
description: '클라우드 기반에서 자율주행 소프트웨어를 설계·빌드·실행까지 빠르게 수행할 수 있는 통합 개발 플랫폼으로, 오픈소스 ADS 기반의 유연한 구조를 통해 기능 수정과 배포가 가능하며 다양한 실차 및 시뮬레이션 환경을 지원합니다.',
|
||||||
|
subtitle: '자율주행 모빌리티 소프트웨어\n통합 개발 환경 (IDE)',
|
||||||
|
detailUrl: 'https://www.futuredrive.net/solutions/astudio',
|
||||||
|
videoPath: './video/ads_ide.mp4',
|
||||||
|
iconKey: 'code',
|
||||||
|
launchUrl: 'http://localhost:5175',
|
||||||
|
devAppPath: './apps/ads-ide/ads-ide-fe',
|
||||||
|
execPath: './ADS-IDE-1.1.0.AppImage',
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'LMS',
|
||||||
|
displayName: 'LMS',
|
||||||
|
description: '자율주행 교육 과정, 실습 콘텐츠, 사용자 학습 이력을 통합 관리하는 학습 관리 시스템입니다.',
|
||||||
|
subtitle: '자율주행 교육 콘텐츠\n학습 관리 시스템',
|
||||||
|
videoPath: './video/lms.mp4',
|
||||||
|
iconKey: 'graduation',
|
||||||
|
launchUrl: 'http://localhost:5176',
|
||||||
|
devAppPath: './apps/lms/lms-fe',
|
||||||
|
execPath: './LMS-1.0.0.AppImage',
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{ appKey: 'Data Management', displayName: 'Data Management', description: '주행 데이터 수집 · 저장 · 분석 플랫폼', videoPath: './video/data_management.mp4', iconKey: 'bar-chart', sortOrder: 30 },
|
||||||
|
{ appKey: 'HMI', displayName: 'HMI', description: '차량 인터페이스 모니터링 및 제어', videoPath: './video/hmi.mp4', iconKey: 'monitor', sortOrder: 40 },
|
||||||
|
{ appKey: 'FMS', displayName: 'FMS', description: '플릿 운영 및 차량 관제 시스템', videoPath: './video/fms.mp4', iconKey: 'truck', sortOrder: 50 },
|
||||||
|
{ appKey: 'ADS Education', displayName: 'ADS Education', description: '자율주행 기술 학습 및 교육 콘텐츠', videoPath: './video/ads_education.mp4', iconKey: 'graduation', sortOrder: 60 },
|
||||||
|
{ appKey: 'AI Benchmark', displayName: 'AI Benchmark', description: 'AI 모델 성능 평가 및 비교 분석', videoPath: './video/ai_benchmark.mp4', iconKey: 'activity', sortOrder: 70 },
|
||||||
|
{ appKey: 'Calibration', displayName: 'Calibration', description: '센서 캘리브레이션 및 설정 도구', videoPath: './video/calibration.mp4', iconKey: 'sliders', sortOrder: 80 },
|
||||||
|
{ appKey: 'IP Repository', displayName: 'IP Repository', description: '지식재산 및 소프트웨어 자산 저장소', videoPath: './video/ip_repository.mp4', iconKey: 'shopping-cart', sortOrder: 90 },
|
||||||
|
{ appKey: 'HDMAP Builder', displayName: 'HDMAP Builder', description: '고정밀 지도 생성 및 편집 도구', videoPath: './video/hdmap_builder.mp4', iconKey: 'map', sortOrder: 100 },
|
||||||
|
]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
import type { ElectronAPI } from '@electron-toolkit/preload'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electron: ElectronAPI
|
||||||
|
appWindowMeta: {
|
||||||
|
isAppWindow: boolean
|
||||||
|
appName: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
launcherTailscale?: {
|
||||||
|
status: () => Promise<{
|
||||||
|
connected: boolean
|
||||||
|
ip: string
|
||||||
|
backendState: string
|
||||||
|
online?: boolean
|
||||||
|
health?: string[]
|
||||||
|
loginName?: string
|
||||||
|
userName?: string
|
||||||
|
peers?: Array<{
|
||||||
|
id: string
|
||||||
|
hostName: string
|
||||||
|
dnsName?: string
|
||||||
|
os?: string
|
||||||
|
userName?: string
|
||||||
|
loginName?: string
|
||||||
|
ip: string
|
||||||
|
ips: string[]
|
||||||
|
online: boolean
|
||||||
|
active: boolean
|
||||||
|
lastSeen?: string
|
||||||
|
relay?: string
|
||||||
|
currentAddress?: string
|
||||||
|
}>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
|
||||||
|
ok: boolean
|
||||||
|
message: string
|
||||||
|
}>
|
||||||
|
logout: () => Promise<{
|
||||||
|
ok: boolean
|
||||||
|
message: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
launcherSshKey?: {
|
||||||
|
ensure: () => Promise<{
|
||||||
|
publicKey: string
|
||||||
|
publicKeyPath: string
|
||||||
|
privateKeyPath: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_VERSION: string
|
||||||
|
readonly VITE_ATNMY_AUTH_API_BASE_URL?: string
|
||||||
|
readonly VITE_ATNMY_STDO_API_BASE_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { LauncherApp } from '../config/apps'
|
||||||
|
|
||||||
|
const DEFAULT_DETAIL_URL = 'https://www.futuredrive.net/solutions/astudio'
|
||||||
|
|
||||||
|
function resolvePackagedAssetPath(path?: string | null): string | null {
|
||||||
|
if (!path) return null
|
||||||
|
if (/^(https?:|file:|data:|blob:)/i.test(path)) return path
|
||||||
|
if (path.startsWith('/video/')) return `.${path}`
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppViewProps {
|
||||||
|
app: LauncherApp
|
||||||
|
isRunning: boolean
|
||||||
|
onShowManual: () => void
|
||||||
|
onFocusApp: () => void
|
||||||
|
onCloseApp: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppView({ app, isRunning, onShowManual, onFocusApp, onCloseApp }: AppViewProps): React.JSX.Element {
|
||||||
|
const [titleFirst, titleRest] = app.displayName.includes(' ')
|
||||||
|
? [app.displayName.split(' ')[0], app.displayName.split(' ').slice(1).join(' ')]
|
||||||
|
: [app.displayName, null]
|
||||||
|
const appTitle = titleRest
|
||||||
|
? <>{titleFirst}<br /><span>{titleRest}</span></>
|
||||||
|
: <span>{titleFirst}</span>
|
||||||
|
const videoPath = resolvePackagedAssetPath(app.videoPath)
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-start relative overflow-hidden pt-[80px] pb-[80px] pl-[52px]">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_65%_55%_at_50%_45%,rgba(59,127,250,0.05)_0%,transparent_65%)]" />
|
||||||
|
|
||||||
|
<div className="text-content">
|
||||||
|
<h1 className="brand-title">
|
||||||
|
{appTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="running-badge">
|
||||||
|
<span className="running-dot" />
|
||||||
|
<span className="text-[12px] font-medium tracking-[0.04em]" style={{ color: '#22c55e' }}>앱 실행 중</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
<p className="description" style={{ opacity: 0.55 }}>{app.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onFocusApp}
|
||||||
|
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(59,127,250,0.35)] bg-[rgba(59,127,250,0.08)] text-[12px] text-[var(--accent)] hover:bg-[rgba(59,127,250,0.18)] hover:border-[rgba(59,127,250,0.6)] cursor-pointer transition-all duration-200"
|
||||||
|
>
|
||||||
|
앱 창으로 이동
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCloseApp}
|
||||||
|
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(255,80,80,0.2)] bg-[rgba(255,80,80,0.04)] text-[12px] hover:bg-[rgba(255,80,80,0.1)] hover:border-[rgba(255,80,80,0.35)] cursor-pointer transition-all duration-200"
|
||||||
|
style={{ color: 'rgba(255,110,110,0.75)' }}
|
||||||
|
>
|
||||||
|
앱 종료
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-start relative overflow-hidden pt-[80px] pb-[80px] pl-[52px]">
|
||||||
|
|
||||||
|
{/* 비디오 배경 */}
|
||||||
|
{videoPath && (
|
||||||
|
<>
|
||||||
|
<video
|
||||||
|
key={videoPath}
|
||||||
|
className="car-video"
|
||||||
|
src={videoPath}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
onError={(e) => { (e.target as HTMLVideoElement).style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
<div className="car-video-shadow" />
|
||||||
|
<div className="floor-reflection" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!videoPath && (
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(59,127,250,0.04)_0%,transparent_70%)]" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 텍스트 */}
|
||||||
|
<div className="text-content">
|
||||||
|
<h1 className="brand-title">
|
||||||
|
{appTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{app.subtitle && (
|
||||||
|
<p className="subtitle">
|
||||||
|
{app.subtitle.split('\n').map((line, i, arr) => (
|
||||||
|
<span key={i}>{line}{i < arr.length - 1 && <br />}</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
<p className="description">{app.description}</p>
|
||||||
|
<div className="mt-5 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(app.detailUrl ?? DEFAULT_DETAIL_URL, '_blank', 'noopener,noreferrer')
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(59,127,250,0.35)] bg-[rgba(59,127,250,0.08)] text-[12px] text-[var(--accent)] hover:bg-[rgba(59,127,250,0.18)] hover:border-[rgba(59,127,250,0.6)] cursor-pointer transition-all duration-200"
|
||||||
|
>
|
||||||
|
자세히 보기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShowManual}
|
||||||
|
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(255,255,255,0.04)] text-[12px] text-[var(--text-secondary)] hover:bg-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.18)] cursor-pointer transition-all duration-200"
|
||||||
|
>
|
||||||
|
사용 매뉴얼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppView
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import TitleBar from '../components/TitleBar'
|
||||||
|
|
||||||
|
interface AppWindowShellProps {
|
||||||
|
appName: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppWindowShell({ appName, url }: AppWindowShellProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen flex flex-col overflow-hidden bg-[#0a0b0e]">
|
||||||
|
<TitleBar appName={appName} />
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
style={{ flex: 1, border: 'none', display: 'block', background: '#0a0b0e' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppWindowShell
|
||||||
@@ -0,0 +1,758 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import TitleBar from '../components/TitleBar'
|
||||||
|
import Sidebar from '../components/Sidebar'
|
||||||
|
import Tooltip from '../components/Tooltip'
|
||||||
|
import AppView from './AppView'
|
||||||
|
import type { LauncherApp } from '../config/apps'
|
||||||
|
import type { AuthSession } from './LoginForm'
|
||||||
|
|
||||||
|
const version = import.meta.env.VITE_APP_VERSION
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
authSession: AuthSession
|
||||||
|
onSessionUpdate: (session: AuthSession) => void
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||||
|
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
|
||||||
|
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
||||||
|
const IS_DEV = import.meta.env.DEV
|
||||||
|
|
||||||
|
const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
||||||
|
const normalizedAppKey = appKey.trim().toLowerCase()
|
||||||
|
if (normalizedAppKey === 'lms') return 'LMS'
|
||||||
|
if (normalizedAppKey === 'ads ide' || normalizedAppKey === 'ads-ide') return 'ADS IDE'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppNotice = {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
linkUrl?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppUpdateAction = 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'
|
||||||
|
|
||||||
|
type AppUpdateStatus = {
|
||||||
|
appKey: string
|
||||||
|
displayName?: string
|
||||||
|
installed: boolean
|
||||||
|
localVersion: string | null
|
||||||
|
latestVersion: string | null
|
||||||
|
status: 'NOT_INSTALLED' | 'UPDATE_AVAILABLE' | 'READY' | 'DISABLED' | 'UNKNOWN_APP'
|
||||||
|
action: AppUpdateAction
|
||||||
|
updateRequired: boolean
|
||||||
|
downloadUrl?: string | null
|
||||||
|
releaseNotes?: string | null
|
||||||
|
canLaunch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstallationInfo = {
|
||||||
|
installed: boolean
|
||||||
|
version: string | null
|
||||||
|
path: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthMeResponse = {
|
||||||
|
user?: AuthSession['user']
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
|
||||||
|
const [apps, setApps] = useState<LauncherApp[]>([])
|
||||||
|
const [activeApp, setActiveApp] = useState('')
|
||||||
|
const [appNotices, setAppNotices] = useState<AppNotice[]>([])
|
||||||
|
const [readNoticeSignatures, setReadNoticeSignatures] = useState<Record<string, string>>(() => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(NOTICE_READ_STORAGE_KEY)
|
||||||
|
return raw ? JSON.parse(raw) as Record<string, string> : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [appStatus, setAppStatus] = useState<AppUpdateStatus | null>(null)
|
||||||
|
const [launcherStatus, setLauncherStatus] = useState<AppUpdateStatus | null>(null)
|
||||||
|
const [noticeOpen, setNoticeOpen] = useState(false)
|
||||||
|
const [updatingApps, setUpdatingApps] = useState<Record<string, boolean>>({})
|
||||||
|
const [updateProgressByApp, setUpdateProgressByApp] = useState<Record<string, number>>({})
|
||||||
|
const [localVersions, setLocalVersions] = useState<Record<string, string>>({})
|
||||||
|
const [installedOverrides, setInstalledOverrides] = useState<Record<string, boolean>>({})
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [showManual, setShowManual] = useState(false)
|
||||||
|
const [runningApps, setRunningApps] = useState<Set<string>>(new Set())
|
||||||
|
const [launchingApp, setLaunchingApp] = useState<string | null>(null)
|
||||||
|
const noticeRef = useRef<HTMLDivElement>(null)
|
||||||
|
const initialAppSelectionDoneRef = useRef(false)
|
||||||
|
|
||||||
|
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
|
||||||
|
const hasNotices = appNotices.length > 0
|
||||||
|
const noticeSignature = appNotices.map((notice) => notice.id).join('|')
|
||||||
|
const hasUnreadNotices = hasNotices && readNoticeSignatures[activeApp] !== noticeSignature
|
||||||
|
const isRunning = runningApps.has(activeApp)
|
||||||
|
const activeLocalVersion = localVersions[activeApp]
|
||||||
|
const appAction = appStatus?.action ?? 'UNAVAILABLE'
|
||||||
|
const hasAppPermission = appConfig !== null && appConfig.canLaunch !== false && appStatus?.canLaunch !== false
|
||||||
|
const canLaunch = hasAppPermission && appAction === 'LAUNCH' && Boolean(appConfig?.launchUrl)
|
||||||
|
const hasAnyUpdatingApp = Object.values(updatingApps).some(Boolean)
|
||||||
|
const activeAppUpdating = Boolean(updatingApps[activeApp])
|
||||||
|
const activeAppLaunching = launchingApp === activeApp
|
||||||
|
const activeUpdateProgress = updateProgressByApp[activeApp] ?? 0
|
||||||
|
const launcherUpdateAvailable = launcherStatus?.action === 'UPDATE'
|
||||||
|
const launcherUpdateMessage = launcherUpdateAvailable
|
||||||
|
? `v${launcherStatus?.latestVersion ?? ''} 업데이트 가능`
|
||||||
|
: launcherStatus
|
||||||
|
? '최신버전'
|
||||||
|
: '업데이트 상태 확인중'
|
||||||
|
|
||||||
|
const refreshSession = useCallback(async (): Promise<AuthSession | null> => {
|
||||||
|
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return null
|
||||||
|
|
||||||
|
const session = await response.json() as AuthSession
|
||||||
|
onSessionUpdate(session)
|
||||||
|
return session
|
||||||
|
}, [onSessionUpdate])
|
||||||
|
|
||||||
|
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||||
|
const request = (accessToken: string): Promise<Response> => fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
let response = await request(authSession.accessToken)
|
||||||
|
if (response.status !== 401) return response
|
||||||
|
|
||||||
|
const refreshed = await refreshSession()
|
||||||
|
if (!refreshed) {
|
||||||
|
onLogout()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await request(refreshed.accessToken)
|
||||||
|
return response
|
||||||
|
}, [authSession.accessToken, onLogout, refreshSession])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const loadUserProfile = async (): Promise<void> => {
|
||||||
|
let session = authSession
|
||||||
|
let response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
const refreshed = await refreshSession()
|
||||||
|
if (!refreshed) {
|
||||||
|
if (!cancelled) onLogout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session = refreshed
|
||||||
|
response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null) as AuthMeResponse | null
|
||||||
|
if (!cancelled && payload?.user) {
|
||||||
|
const nextSession = {
|
||||||
|
...session,
|
||||||
|
user: payload.user,
|
||||||
|
}
|
||||||
|
if (JSON.stringify(nextSession.user) === JSON.stringify(authSession.user) && nextSession.accessToken === authSession.accessToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSessionUpdate({
|
||||||
|
...nextSession,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadUserProfile()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [authSession, onLogout, onSessionUpdate, refreshSession])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/apps`)
|
||||||
|
.then((response) => response.ok ? response.json() : null)
|
||||||
|
.then((payload: { apps?: LauncherApp[] } | null) => {
|
||||||
|
if (cancelled || !payload?.apps?.length) return
|
||||||
|
setApps(payload.apps)
|
||||||
|
const currentApp = payload.apps.find((app) => app.appKey === activeApp)
|
||||||
|
const currentAppAvailable = currentApp?.canLaunch !== false && currentApp?.canView !== false
|
||||||
|
const firstAvailableApp = payload.apps.find((app) => app.canView !== false && app.canLaunch !== false)
|
||||||
|
const lastLaunchedAppKey = window.localStorage.getItem(LAST_LAUNCHED_APP_STORAGE_KEY)
|
||||||
|
const lastLaunchedApp = lastLaunchedAppKey
|
||||||
|
? payload.apps.find((app) => app.appKey === lastLaunchedAppKey && app.canView !== false && app.canLaunch !== false)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!initialAppSelectionDoneRef.current) {
|
||||||
|
initialAppSelectionDoneRef.current = true
|
||||||
|
setActiveApp(lastLaunchedApp?.appKey ?? firstAvailableApp?.appKey ?? currentApp?.appKey ?? payload.apps[0].appKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentApp || !currentAppAvailable) {
|
||||||
|
setActiveApp(firstAvailableApp?.appKey ?? currentApp?.appKey ?? payload.apps[0].appKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [activeApp, fetchWithAuthRefresh])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNoticeOpen(false)
|
||||||
|
setAppStatus(null)
|
||||||
|
setShowManual(false)
|
||||||
|
}, [activeApp])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
if (!activeApp) {
|
||||||
|
setAppNotices([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`${LAUNCHER_API_BASE_URL}/api/notices/apps/${encodeURIComponent(activeApp)}`)
|
||||||
|
.then((response) => response.ok ? response.json() : null)
|
||||||
|
.then((payload: { notices?: AppNotice[] } | null) => {
|
||||||
|
if (!cancelled) setAppNotices(payload?.notices ?? [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setAppNotices([])
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [activeApp])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeApp || !appConfig) {
|
||||||
|
setAppStatus(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const loadStatus = async (): Promise<void> => {
|
||||||
|
let installation: InstallationInfo = { installed: false, version: null, path: null }
|
||||||
|
|
||||||
|
try {
|
||||||
|
installation = await window.electron.ipcRenderer.invoke('get-app-installation', {
|
||||||
|
appName: activeApp,
|
||||||
|
execPath: appConfig.execPath,
|
||||||
|
devAppPath: appConfig.devAppPath,
|
||||||
|
}) as InstallationInfo
|
||||||
|
} catch {
|
||||||
|
installation = { installed: false, version: null, path: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IS_DEV && appConfig.devAppPath && installation.installed) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAppStatus({
|
||||||
|
appKey: activeApp,
|
||||||
|
displayName: appConfig.displayName,
|
||||||
|
installed: true,
|
||||||
|
localVersion: installation.version,
|
||||||
|
latestVersion: installation.version,
|
||||||
|
status: 'READY',
|
||||||
|
action: 'LAUNCH',
|
||||||
|
updateRequired: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = installedOverrides[activeApp] ?? installation.installed
|
||||||
|
const currentVersion = installation.version ?? activeLocalVersion ?? ''
|
||||||
|
if (installation.version) {
|
||||||
|
setLocalVersions((prev) => ({ ...prev, [activeApp]: installation.version as string }))
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
currentVersion,
|
||||||
|
installed: String(installed),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/apps/${encodeURIComponent(activeApp)}/update?${query}`)
|
||||||
|
const status = response.ok ? await response.json() as AppUpdateStatus : null
|
||||||
|
if (!cancelled) setAppStatus(status)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setAppStatus(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStatus()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [activeApp, activeLocalVersion, appConfig, fetchWithAuthRefresh, installedOverrides])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
currentVersion: version,
|
||||||
|
installed: 'true',
|
||||||
|
})
|
||||||
|
|
||||||
|
fetch(`${LAUNCHER_API_BASE_URL}/api/apps/launcher/update?${query}`)
|
||||||
|
.then((response) => response.ok ? response.json() : null)
|
||||||
|
.then((status: AppUpdateStatus | null) => {
|
||||||
|
if (!cancelled) setLauncherStatus(status)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLauncherStatus(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (_event: unknown, appName: string): void => {
|
||||||
|
setRunningApps((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(appName)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.electron.ipcRenderer.on('app-window-closed', handler)
|
||||||
|
return () => {
|
||||||
|
window.electron.ipcRenderer.removeListener('app-window-closed', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (_event: unknown, payload: { appName: string; progress: number }): void => {
|
||||||
|
setUpdateProgressByApp((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[payload.appName]: payload.progress,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on('app-download-progress', handler)
|
||||||
|
return () => {
|
||||||
|
window.electron.ipcRenderer.removeListener('app-download-progress', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const completeLocalUpdate = useCallback((appKey: string, latestVersion?: string | null): void => {
|
||||||
|
if (!latestVersion) return
|
||||||
|
setLocalVersions((prev) => ({ ...prev, [appKey]: latestVersion }))
|
||||||
|
setInstalledOverrides((prev) => ({ ...prev, [appKey]: true }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const markAppNoticesRead = useCallback((): void => {
|
||||||
|
if (!hasNotices) return
|
||||||
|
|
||||||
|
setReadNoticeSignatures((prev) => {
|
||||||
|
const next = {
|
||||||
|
...prev,
|
||||||
|
[activeApp]: noticeSignature,
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(NOTICE_READ_STORAGE_KEY, JSON.stringify(next))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [activeApp, hasNotices, noticeSignature])
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(async (
|
||||||
|
targetApp: string,
|
||||||
|
latestVersion?: string | null,
|
||||||
|
downloadUrl?: string | null,
|
||||||
|
) => {
|
||||||
|
if (!downloadUrl) {
|
||||||
|
alert('다운로드 URL이 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasAnyUpdatingApp) {
|
||||||
|
alert('이미 업데이트가 진행 중입니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (targetApp !== 'launcher' && !appConfig) {
|
||||||
|
alert('선택된 앱이 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingApps((prev) => ({ ...prev, [targetApp]: true }))
|
||||||
|
setUpdateProgressByApp((prev) => ({ ...prev, [targetApp]: 0 }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targetApp === 'launcher') {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke('install-launcher-update', {
|
||||||
|
downloadUrl,
|
||||||
|
latestVersion,
|
||||||
|
}) as InstallationInfo
|
||||||
|
const installedVersion = result.version ?? latestVersion
|
||||||
|
completeLocalUpdate(targetApp, installedVersion)
|
||||||
|
setLauncherStatus((prev) => prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
installed: true,
|
||||||
|
localVersion: installedVersion ?? prev.latestVersion,
|
||||||
|
status: 'READY',
|
||||||
|
action: 'LAUNCH',
|
||||||
|
updateRequired: false,
|
||||||
|
}
|
||||||
|
: prev)
|
||||||
|
} else {
|
||||||
|
const result = await window.electron.ipcRenderer.invoke('download-app', {
|
||||||
|
appName: targetApp,
|
||||||
|
downloadUrl,
|
||||||
|
execPath: appConfig?.execPath,
|
||||||
|
latestVersion,
|
||||||
|
}) as InstallationInfo
|
||||||
|
|
||||||
|
if (result.version) {
|
||||||
|
completeLocalUpdate(targetApp, result.version)
|
||||||
|
} else {
|
||||||
|
completeLocalUpdate(targetApp, latestVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '다운로드에 실패했습니다.'
|
||||||
|
alert(message)
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
setUpdatingApps((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[targetApp]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setUpdateProgressByApp((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[targetApp]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}, [appConfig, completeLocalUpdate, hasAnyUpdatingApp])
|
||||||
|
|
||||||
|
const createHandoffToken = useCallback(async (audienceApp: string): Promise<string | null> => {
|
||||||
|
const requestHandoff = async (accessToken: string): Promise<Response> => {
|
||||||
|
return fetch(`${LAUNCHER_API_BASE_URL}/api/auth/handoff`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ audienceApp }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await requestHandoff(authSession.accessToken)
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
const refreshResponse = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (refreshResponse.ok) {
|
||||||
|
const nextSession = await refreshResponse.json() as AuthSession
|
||||||
|
onSessionUpdate(nextSession)
|
||||||
|
response = await requestHandoff(nextSession.accessToken)
|
||||||
|
} else {
|
||||||
|
alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.')
|
||||||
|
onLogout()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
|
||||||
|
const detail = payload?.message ?? payload?.error ?? `HTTP ${response.status}`
|
||||||
|
alert(`${audienceApp} 인증 연동에 실패했습니다. (${detail})`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handoff = await response.json() as { handoffToken: string }
|
||||||
|
return handoff.handoffToken
|
||||||
|
}, [authSession.accessToken, onLogout, onSessionUpdate])
|
||||||
|
|
||||||
|
const handleLaunch = useCallback(async () => {
|
||||||
|
if (!appConfig || !canLaunch || launchingApp) return
|
||||||
|
|
||||||
|
setLaunchingApp(activeApp)
|
||||||
|
const launchDelay = new Promise((resolve) => window.setTimeout(resolve, 1500))
|
||||||
|
|
||||||
|
let handoffCode: string | undefined
|
||||||
|
try {
|
||||||
|
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
|
||||||
|
if (handoffAudienceApp) {
|
||||||
|
const [handoffToken] = await Promise.all([
|
||||||
|
createHandoffToken(handoffAudienceApp),
|
||||||
|
launchDelay,
|
||||||
|
])
|
||||||
|
handoffCode = handoffToken ?? undefined
|
||||||
|
if (!handoffCode) {
|
||||||
|
setLaunchingApp(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await launchDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.send('launch-app', {
|
||||||
|
appName: activeApp,
|
||||||
|
url: appConfig.launchUrl,
|
||||||
|
devAppPath: appConfig.devAppPath,
|
||||||
|
execPath: appConfig.execPath,
|
||||||
|
handoffCode,
|
||||||
|
audienceApp: resolveHandoffAudienceApp(activeApp) ?? activeApp,
|
||||||
|
})
|
||||||
|
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
||||||
|
setRunningApps((prev) => new Set([...prev, activeApp]))
|
||||||
|
} finally {
|
||||||
|
setLaunchingApp(null)
|
||||||
|
}
|
||||||
|
}, [activeApp, appConfig, canLaunch, createHandoffToken, launchingApp])
|
||||||
|
|
||||||
|
const handleFocusApp = useCallback(() => {
|
||||||
|
window.electron.ipcRenderer.send('focus-app-window', activeApp)
|
||||||
|
}, [activeApp])
|
||||||
|
|
||||||
|
const handleCloseApp = useCallback(() => {
|
||||||
|
window.electron.ipcRenderer.send('close-app-window', activeApp)
|
||||||
|
}, [activeApp])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!noticeOpen) return
|
||||||
|
const handler = (e: MouseEvent): void => {
|
||||||
|
if (noticeRef.current && !noticeRef.current.contains(e.target as Node)) {
|
||||||
|
setNoticeOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [noticeOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen overflow-hidden flex flex-col bg-[var(--bg-deep)]">
|
||||||
|
<TitleBar />
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<Sidebar
|
||||||
|
apps={apps}
|
||||||
|
userName={authSession.user.name || authSession.user.email.split('@')[0] || '사용자'}
|
||||||
|
userEmail={authSession.user.email}
|
||||||
|
userRole={authSession.user.role}
|
||||||
|
userCreatedAt={authSession.user.createdAt}
|
||||||
|
userLastLoginAt={authSession.user.lastLoginAt}
|
||||||
|
primaryAffiliation={authSession.user.primaryAffiliation}
|
||||||
|
currentLicense={authSession.user.currentLicense}
|
||||||
|
activeApp={activeApp}
|
||||||
|
onAppChange={setActiveApp}
|
||||||
|
onLogout={onLogout}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
|
||||||
|
launcherUpdateAvailable={launcherUpdateAvailable}
|
||||||
|
launcherUpdating={Boolean(updatingApps.launcher)}
|
||||||
|
launcherUpdateMessage={launcherUpdateMessage}
|
||||||
|
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
|
||||||
|
authAccessToken={authSession.accessToken}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="main-content">
|
||||||
|
{/* 우상단 영역 */}
|
||||||
|
<div className="absolute top-[28px] right-[32px] z-10 flex items-start gap-3">
|
||||||
|
|
||||||
|
{!showManual && (
|
||||||
|
<>
|
||||||
|
{/* 공지사항 버튼 + 드롭다운 */}
|
||||||
|
<div className="relative" ref={noticeRef}>
|
||||||
|
<Tooltip label="공지사항">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
markAppNoticesRead()
|
||||||
|
setNoticeOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
className="notice-icon-btn"
|
||||||
|
style={{ opacity: hasNotices ? 1 : 0.35, cursor: hasNotices ? 'pointer' : 'default' }}
|
||||||
|
disabled={!hasNotices}
|
||||||
|
>
|
||||||
|
{hasUnreadNotices && <span className="notice-badge" />}
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{noticeOpen && hasNotices && (
|
||||||
|
<div className="notice-dropdown">
|
||||||
|
<div className="flex items-center justify-between mb-3 px-4 pt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="notice-dot" />
|
||||||
|
<span className="notice-label">공지사항</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-[var(--text-muted)]">{appNotices.length}건</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{appNotices.map((n, i) => (
|
||||||
|
<div key={n.id} className={`notice-item${i < appNotices.length - 1 ? ' notice-item-border' : ''}`}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="notice-title">{n.title}</p>
|
||||||
|
<span className="notice-date">{n.date}</span>
|
||||||
|
</div>
|
||||||
|
<p className="notice-body">{n.body}</p>
|
||||||
|
{n.linkUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="notice-link-btn"
|
||||||
|
onClick={() => window.open(n.linkUrl ?? '', '_blank', 'noopener,noreferrer')}
|
||||||
|
>
|
||||||
|
자세히보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 업데이트 / 실행 버튼 — 앱 실행 중일 때는 숨김 */}
|
||||||
|
{!isRunning && hasAppPermission && (
|
||||||
|
appAction === 'INSTALL' || appAction === 'UPDATE' ? (
|
||||||
|
<div className="flex flex-col items-end gap-[7px]">
|
||||||
|
{activeAppUpdating ? (
|
||||||
|
<button className="updating-btn" disabled>
|
||||||
|
<svg className="spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
|
||||||
|
<path d="M12 2a10 10 0 1 0 10 10" />
|
||||||
|
</svg>
|
||||||
|
{appAction === 'INSTALL' ? '설치중' : '업데이트중'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="update-btn" onClick={() => handleUpdate(activeApp, appStatus?.latestVersion, appStatus?.downloadUrl)} disabled={hasAnyUpdatingApp || !hasAppPermission}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
{appAction === 'INSTALL' ? '설치' : '업데이트'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{activeAppUpdating && (
|
||||||
|
<div className="update-progress-wrap">
|
||||||
|
<div className="update-progress-track">
|
||||||
|
<div className="update-progress-fill" style={{ width: `${activeUpdateProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="update-progress-pct">{activeUpdateProgress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : canLaunch ? (
|
||||||
|
activeAppLaunching ? (
|
||||||
|
<button className="launch-pending-btn" disabled>
|
||||||
|
실행중
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="launch-btn" onClick={handleLaunch}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||||
|
</svg>
|
||||||
|
실행
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<button className="updating-btn" disabled>
|
||||||
|
{appStatus ? '미지원' : '확인중'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
{showManual ? (
|
||||||
|
<>
|
||||||
|
{/* 매뉴얼 헤더 바 */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-[48px] flex items-center px-4 gap-3 border-b border-[var(--border)] bg-[var(--bg-sidebar)] z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManual(false)}
|
||||||
|
className="flex items-center justify-center w-7 h-7 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-[13px] font-medium text-[var(--text-secondary)]">사용 매뉴얼</span>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src="appdocs://host/install.html"
|
||||||
|
className="absolute left-0 right-0 bottom-0 border-none"
|
||||||
|
style={{ top: '48px', width: '100%', height: 'calc(100% - 48px)', background: '#0a0b0e' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : appConfig ? (
|
||||||
|
<AppView
|
||||||
|
app={appConfig}
|
||||||
|
isRunning={isRunning}
|
||||||
|
onShowManual={() => setShowManual(true)}
|
||||||
|
onFocusApp={handleFocusApp}
|
||||||
|
onCloseApp={handleCloseApp}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 items-center justify-center px-10 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[15px] font-medium text-[var(--text-secondary)]">연결된 앱이 없습니다.</p>
|
||||||
|
<p className="mt-2 text-[12px] text-[var(--text-muted)]">관리 콘솔에서 앱을 연결하면 이 화면에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showManual && (
|
||||||
|
<div className="launcher-version-wrap">
|
||||||
|
{launcherUpdateAvailable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="launcher-update-text"
|
||||||
|
onClick={() => handleUpdate('launcher', launcherStatus.latestVersion, launcherStatus.downloadUrl)}
|
||||||
|
disabled={hasAnyUpdatingApp}
|
||||||
|
>
|
||||||
|
업데이트
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="version-label">v{localVersions.launcher ?? version}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Dashboard
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import TitleBar from '../components/TitleBar'
|
||||||
|
import LoginForm from './LoginForm'
|
||||||
|
import type { AuthSession } from './LoginForm'
|
||||||
|
import PasswordResetForm from './PasswordResetForm'
|
||||||
|
import SignupForm from './SignupForm'
|
||||||
|
|
||||||
|
type View = 'login' | 'signup' | 'password-reset'
|
||||||
|
|
||||||
|
type LoginNotice = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
linkUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type LauncherUpdateStatus = {
|
||||||
|
latestVersion: string | null
|
||||||
|
action: 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'
|
||||||
|
downloadUrl?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
onLogin: (session: AuthSession) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = import.meta.env.VITE_APP_VERSION
|
||||||
|
const TYPING_TEXT = '클라우드 기반 자율주행 모빌리티\n소프트웨어 개발, 검증 및 운영 환경.'
|
||||||
|
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||||
|
const DISMISSED_LOGIN_NOTICE_KEY = 'atnmy.login.dismissedNoticeId'
|
||||||
|
|
||||||
|
function readDismissedLoginNoticeId(): string {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(DISMISSED_LOGIN_NOTICE_KEY) ?? ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeDismissedLoginNoticeId(noticeId: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DISMISSED_LOGIN_NOTICE_KEY, noticeId)
|
||||||
|
} catch {
|
||||||
|
// Notice dismissal should not break the login screen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login({ onLogin }: LoginProps): React.JSX.Element {
|
||||||
|
const [view, setView] = useState<View>('login')
|
||||||
|
const [noticeDismissed, setNoticeDismissed] = useState(false)
|
||||||
|
const [notice, setNotice] = useState<LoginNotice | null>(null)
|
||||||
|
const [launcherStatus, setLauncherStatus] = useState<LauncherUpdateStatus | null>(null)
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
const [updateProgress, setUpdateProgress] = useState(0)
|
||||||
|
const [typedCount, setTypedCount] = useState(0)
|
||||||
|
const [loginLoading, setLoginLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
fetch(`${LAUNCHER_API_BASE_URL}/api/notices/login`)
|
||||||
|
.then((response) => response.ok ? response.json() : null)
|
||||||
|
.then((payload: { notice?: LoginNotice | null } | null) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const nextNotice = payload?.notice ?? null
|
||||||
|
setNotice(nextNotice && readDismissedLoginNoticeId() !== nextNotice.id ? nextNotice : null)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setNotice(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
currentVersion: version,
|
||||||
|
installed: 'true',
|
||||||
|
})
|
||||||
|
|
||||||
|
fetch(`${LAUNCHER_API_BASE_URL}/api/apps/launcher/update?${query}`)
|
||||||
|
.then((response) => response.ok ? response.json() : null)
|
||||||
|
.then((status: LauncherUpdateStatus | null) => {
|
||||||
|
if (!cancelled) setLauncherStatus(status)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLauncherStatus(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (_event: unknown, payload: { appName: string; progress: number }): void => {
|
||||||
|
if (payload.appName === 'launcher') setUpdateProgress(payload.progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on('app-download-progress', handler)
|
||||||
|
return () => {
|
||||||
|
window.electron.ipcRenderer.removeListener('app-download-progress', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLauncherUpdate = async (): Promise<void> => {
|
||||||
|
if (!launcherStatus?.downloadUrl) {
|
||||||
|
alert('다운로드 URL이 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdating(true)
|
||||||
|
setUpdateProgress(0)
|
||||||
|
try {
|
||||||
|
await window.electron.ipcRenderer.invoke('install-launcher-update', {
|
||||||
|
downloadUrl: launcherStatus.downloadUrl,
|
||||||
|
latestVersion: launcherStatus.latestVersion,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '런처 업데이트에 실패했습니다.'
|
||||||
|
alert(message)
|
||||||
|
} finally {
|
||||||
|
setUpdating(false)
|
||||||
|
setUpdateProgress(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const typeNext = (count: number): void => {
|
||||||
|
if (cancelled) return
|
||||||
|
setTypedCount(count)
|
||||||
|
|
||||||
|
if (count >= TYPING_TEXT.length) {
|
||||||
|
timer = setTimeout(() => typeNext(0), 3000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(() => typeNext(count + 1), 55)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = setTimeout(() => typeNext(0), 900)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearTimeout(start)
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen overflow-hidden flex flex-col bg-[var(--bg-deep)]">
|
||||||
|
<TitleBar />
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden relative">
|
||||||
|
{/* 플랫폼 공지 배너 — absolute로 레이아웃 영향 없음 */}
|
||||||
|
{notice && !noticeDismissed && (
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-[100] flex items-center gap-3 px-5 py-[9px] bg-[rgba(22,27,42,0.92)] border-b border-[rgba(59,127,250,0.15)]">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<p className="flex-1 text-[11.5px] text-[var(--text-secondary)] leading-none">
|
||||||
|
<span className="text-[var(--accent)] font-semibold mr-2">[{notice.title}]</span>
|
||||||
|
{notice.body}
|
||||||
|
{notice.linkUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(notice.linkUrl ?? '', '_blank', 'noopener,noreferrer')
|
||||||
|
}}
|
||||||
|
className="ml-2 text-[var(--accent)] hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
자세히 보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
storeDismissedLoginNoticeId(notice.id)
|
||||||
|
setNoticeDismissed(true)
|
||||||
|
}}
|
||||||
|
className="shrink-0 flex items-center justify-center w-5 h-5 rounded text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loginLoading && (
|
||||||
|
<div className="absolute inset-0 z-50 bg-[rgba(0,0,0,0.45)] pointer-events-none animate-[fadeIn_0.2s_ease]" />
|
||||||
|
)}
|
||||||
|
<div className="login-version-wrap">
|
||||||
|
{launcherStatus?.action === 'UPDATE' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="launcher-update-text"
|
||||||
|
onClick={handleLauncherUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
>
|
||||||
|
{updating ? `업데이트 ${updateProgress}%` : '업데이트'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="version-label">v{version}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left — 브랜딩 + 동영상 */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
<video className="login-video" src="./video/main_video.mp4" autoPlay loop muted playsInline />
|
||||||
|
<div className="login-video-overlay" />
|
||||||
|
|
||||||
|
<div className="absolute top-[72px] left-[52px] z-[2] animate-reveal">
|
||||||
|
<h1 className="brand-title" style={{ marginBottom: 14, fontSize: 44 }}>
|
||||||
|
Autonomy<br /><span>Studio</span>
|
||||||
|
</h1>
|
||||||
|
<div className="divider" style={{ marginBottom: 16 }} />
|
||||||
|
<p className="description">
|
||||||
|
{(() => {
|
||||||
|
const text = TYPING_TEXT.slice(0, typedCount)
|
||||||
|
const [line1, line2] = text.split('\n')
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{line1}
|
||||||
|
{line2 !== undefined && <><br />{line2}</>}
|
||||||
|
<span className="typing-cursor" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — 슬라이딩 폼 패널 */}
|
||||||
|
<div className="login-panel w-[420px] shrink-0 relative overflow-hidden">
|
||||||
|
<div className="login-panel-edge" />
|
||||||
|
<div className="login-panel-glow" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-full transition-transform duration-[420ms] ease-in-out"
|
||||||
|
style={{
|
||||||
|
width: '1260px',
|
||||||
|
transform: view === 'login'
|
||||||
|
? 'translateX(0)'
|
||||||
|
: view === 'signup'
|
||||||
|
? 'translateX(-420px)'
|
||||||
|
: 'translateX(-840px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-[420px] flex items-center justify-center">
|
||||||
|
<LoginForm
|
||||||
|
onLogin={onLogin}
|
||||||
|
onGoSignup={() => setView('signup')}
|
||||||
|
onGoPasswordReset={() => setView('password-reset')}
|
||||||
|
onLoadingChange={setLoginLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-[420px] flex items-center justify-center">
|
||||||
|
<SignupForm onGoLogin={() => setView('login')} onSignupComplete={onLogin} />
|
||||||
|
</div>
|
||||||
|
<div className="w-[420px] flex items-center justify-center">
|
||||||
|
<PasswordResetForm onGoLogin={() => setView('login')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
onLogin: (session: AuthSession) => void
|
||||||
|
onGoSignup: () => void
|
||||||
|
onGoPasswordReset: () => void
|
||||||
|
onLoadingChange: (v: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
accessToken: string
|
||||||
|
accessTokenExpiresIn?: number
|
||||||
|
refreshToken?: string
|
||||||
|
refreshTokenExpiresIn?: number
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string | null
|
||||||
|
role: string
|
||||||
|
roles?: string[]
|
||||||
|
createdAt?: string
|
||||||
|
lastLoginAt?: string | null
|
||||||
|
primaryAffiliation?: {
|
||||||
|
id: string
|
||||||
|
organizationId: string
|
||||||
|
organizationName: string
|
||||||
|
organizationType: string
|
||||||
|
department: string | null
|
||||||
|
position: string | null
|
||||||
|
} | null
|
||||||
|
currentLicense?: {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
startsAt: string
|
||||||
|
expiresAt: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
const MIN_LOGIN_DURATION_MS = 2000
|
||||||
|
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||||
|
const LAST_LOGIN_EMAIL_KEY = 'atnmy.auth.last-login-email'
|
||||||
|
|
||||||
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
type TailscaleProvisionResponse = {
|
||||||
|
installTailscale?: boolean
|
||||||
|
tailscaleAuthKey?: string
|
||||||
|
tailscaleLoginServer?: string
|
||||||
|
tailscaleHostname?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingTailscaleConfirm = {
|
||||||
|
session: AuthSession
|
||||||
|
email: string
|
||||||
|
keepLogin: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProvisionErrorMessage(payload: unknown): string {
|
||||||
|
if (!payload) return ''
|
||||||
|
if (typeof payload === 'string') return payload
|
||||||
|
if (Array.isArray(payload)) return payload.map(extractProvisionErrorMessage).filter(Boolean).join(', ')
|
||||||
|
if (typeof payload !== 'object') return ''
|
||||||
|
|
||||||
|
const record = payload as Record<string, unknown>
|
||||||
|
const candidates = [record.message, record.details, record.error]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const message = extractProvisionErrorMessage(candidate)
|
||||||
|
if (message && message !== '[object Object]' && !/^(bad gateway|internal server error|unauthorized|forbidden)$/i.test(message)) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeadscaleAccount(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/@/g, '-at-')
|
||||||
|
.replace(/[^a-z0-9-]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount: string): boolean {
|
||||||
|
if (!statusAccount) return false
|
||||||
|
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLastLoginEmail(): string {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(LAST_LOGIN_EMAIL_KEY) ?? ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeLastLoginEmail(email: string): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email)
|
||||||
|
} catch {
|
||||||
|
// Login should not fail if local storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLauncherTailscale(account: string, password: string): Promise<void> {
|
||||||
|
if (!window.launcherTailscale) return
|
||||||
|
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
|
||||||
|
|
||||||
|
const status = await window.launcherTailscale.status().catch(() => null)
|
||||||
|
const isSameAccount = Boolean(
|
||||||
|
status &&
|
||||||
|
(
|
||||||
|
isSameTailscaleAccount(status.loginName, account) ||
|
||||||
|
isSameTailscaleAccount(status.userName, account)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
status?.connected &&
|
||||||
|
isSameAccount
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
status?.backendState === 'Unavailable' &&
|
||||||
|
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
account,
|
||||||
|
password,
|
||||||
|
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
|
||||||
|
usageType: 'launcher-client',
|
||||||
|
publicSshKey: sshKey?.publicKey,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as unknown
|
||||||
|
const message = extractProvisionErrorMessage(payload)
|
||||||
|
throw new Error(message ? `Tailscale 연결 정보를 가져오지 못했습니다: ${message}` : 'Tailscale 연결 정보를 가져오지 못했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const provision = await response.json() as TailscaleProvisionResponse
|
||||||
|
if (!provision.tailscaleAuthKey) {
|
||||||
|
throw new Error('Tailscale 인증키가 없습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.launcherTailscale.login(
|
||||||
|
provision.tailscaleAuthKey,
|
||||||
|
provision.tailscaleLoginServer ?? '',
|
||||||
|
provision.tailscaleHostname,
|
||||||
|
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
|
||||||
|
)
|
||||||
|
if (!result.ok) {
|
||||||
|
const nextStatus = await window.launcherTailscale.status().catch(() => null)
|
||||||
|
if (nextStatus?.connected) return
|
||||||
|
throw new Error(result.message || 'Tailscale 연결에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }: LoginFormProps): React.JSX.Element {
|
||||||
|
const [email, setEmail] = useState(() => readLastLoginEmail())
|
||||||
|
const [password, setPassword] = useState('qwer1234')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [keepLogin, setKeepLogin] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null)
|
||||||
|
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||||
|
|
||||||
|
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
|
||||||
|
storeLastLoginEmail(loginEmail.trim())
|
||||||
|
if (persistLogin) {
|
||||||
|
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||||
|
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
|
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||||
|
}
|
||||||
|
const elapsed = Date.now() - startedAt
|
||||||
|
if (elapsed < MIN_LOGIN_DURATION_MS) {
|
||||||
|
await delay(MIN_LOGIN_DURATION_MS - elapsed)
|
||||||
|
}
|
||||||
|
onLogin(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (emailError) return
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setPendingTailscaleConfirm(null)
|
||||||
|
onLoadingChange(true)
|
||||||
|
const startedAt = Date.now()
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('이메일 또는 비밀번호를 확인하세요.')
|
||||||
|
}
|
||||||
|
const session = await response.json() as AuthSession
|
||||||
|
try {
|
||||||
|
await ensureLauncherTailscale(email, password)
|
||||||
|
} catch (tailscaleError) {
|
||||||
|
setLoading(false)
|
||||||
|
onLoadingChange(false)
|
||||||
|
setPendingTailscaleConfirm({
|
||||||
|
session,
|
||||||
|
email,
|
||||||
|
keepLogin,
|
||||||
|
message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await completeLogin(session, email, keepLogin, startedAt)
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
onLoadingChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continueWithoutTailscale = async (): Promise<void> => {
|
||||||
|
if (!pendingTailscaleConfirm) return
|
||||||
|
const pending = pendingTailscaleConfirm
|
||||||
|
setPendingTailscaleConfirm(null)
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
onLoadingChange(true)
|
||||||
|
try {
|
||||||
|
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
|
||||||
|
} catch (continueError) {
|
||||||
|
setError(continueError instanceof Error ? continueError.message : '로그인에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
onLoadingChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late">
|
||||||
|
|
||||||
|
<h2 className="text-[20px] font-bold text-[var(--text-primary)] mb-1">로그인</h2>
|
||||||
|
<p className="text-[12px] text-[var(--text-muted)] mb-6">계정에 로그인하여 서비스를 이용하세요.</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<EnvelopeIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type="email"
|
||||||
|
placeholder="이메일"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<LockIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="비밀번호"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<EyeToggle show={showPassword} onClick={() => setShowPassword((v) => !v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={keepLogin}
|
||||||
|
onChange={(e) => setKeepLogin(e.target.checked)}
|
||||||
|
className="login-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-[var(--text-secondary)]">로그인 상태 유지</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={onGoPasswordReset} className="text-[12px] text-[var(--accent)] hover:text-[#6aa0ff] hover:underline transition-colors cursor-pointer">
|
||||||
|
비밀번호 찾기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex h-5 items-center justify-center">
|
||||||
|
<p className={`text-center text-[12px] text-red-300 transition-opacity ${error ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
|
{error || 'placeholder'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="login-btn w-full mb-4" disabled={loading || Boolean(emailError)} style={{ opacity: loading || emailError ? 0.7 : 1 }}>
|
||||||
|
{loading ? '로그인 중...' : '로그인'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||||
|
<span className="text-[11px] text-[var(--text-muted)]">또는</span>
|
||||||
|
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="login-google-btn w-full mb-6">
|
||||||
|
<GoogleIcon />
|
||||||
|
Google로 로그인
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-[12px] text-[var(--text-muted)]">
|
||||||
|
계정이 없으신가요?{' '}
|
||||||
|
<button type="button" onClick={onGoSignup} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
|
||||||
|
회원가입
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
{pendingTailscaleConfirm && createPortal(
|
||||||
|
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/45 px-6 backdrop-blur-[2px]">
|
||||||
|
<section className="w-full max-w-[380px] overflow-hidden rounded-xl border border-white/14 bg-[#121722] shadow-[0_28px_80px_rgba(0,0,0,0.45)]">
|
||||||
|
<div className="border-b border-white/10 px-5 py-4">
|
||||||
|
<h3 className="text-[15px] font-bold text-white">Tailscale 연결 실패</h3>
|
||||||
|
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/62">
|
||||||
|
플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
<div className="max-h-[96px] overflow-y-auto rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-[11px] font-medium leading-relaxed text-white/56">
|
||||||
|
{pendingTailscaleConfirm.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingTailscaleConfirm(null)}
|
||||||
|
className="h-9 cursor-pointer rounded-md border border-white/16 bg-white/6 px-4 text-[12px] font-bold text-white/72 transition hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void continueWithoutTailscale()}
|
||||||
|
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea]"
|
||||||
|
>
|
||||||
|
계속 진행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginForm
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { EnvelopeIcon, EyeToggle, LockIcon } from '../components/AuthIcons'
|
||||||
|
|
||||||
|
interface PasswordResetFormProps {
|
||||||
|
onGoLogin: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_BASE_URL = (
|
||||||
|
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
|
||||||
|
).replace(/\/+$/, '')
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
function PasswordResetForm({ onGoLogin }: PasswordResetFormProps): React.JSX.Element {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [verificationCode, setVerificationCode] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false)
|
||||||
|
const [codeSent, setCodeSent] = useState(false)
|
||||||
|
const [sendingCode, setSendingCode] = useState(false)
|
||||||
|
const [codeTimer, setCodeTimer] = useState(0)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (codeTimer <= 0) return
|
||||||
|
const timer = setTimeout(() => setCodeTimer((value) => value - 1), 1000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [codeTimer])
|
||||||
|
|
||||||
|
const handleSendCode = async (): Promise<void> => {
|
||||||
|
if (emailError) return
|
||||||
|
setSendingCode(true)
|
||||||
|
setVerificationCode('')
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AUTH_BASE_URL}/auth/password-reset/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('가입되지 않은 이메일입니다.')
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error('인증코드 발송에 실패했습니다.')
|
||||||
|
|
||||||
|
setCodeSent(true)
|
||||||
|
setCodeTimer(60)
|
||||||
|
} catch (sendError) {
|
||||||
|
setCodeSent(false)
|
||||||
|
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setSendingCode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
|
||||||
|
if (emailError) {
|
||||||
|
setError('올바른 이메일 형식으로 입력해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^\d{6}$/.test(verificationCode)) {
|
||||||
|
setError('이메일 인증코드 6자리를 입력해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('비밀번호는 8자 이상이어야 합니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
setError('비밀번호가 일치하지 않습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AUTH_BASE_URL}/auth/password-reset/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
verificationCode,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 400) {
|
||||||
|
throw new Error('이메일 또는 인증코드를 확인해주세요.')
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('비밀번호 변경에 실패했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess('비밀번호가 변경되었습니다. 새 비밀번호로 로그인하세요.')
|
||||||
|
setPassword('')
|
||||||
|
setPasswordConfirm('')
|
||||||
|
setVerificationCode('')
|
||||||
|
setCodeSent(false)
|
||||||
|
setCodeTimer(0)
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : '비밀번호 변경에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onGoLogin}
|
||||||
|
className="flex items-center justify-center w-6 h-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M15 18l-6-6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 className="text-[20px] font-bold text-[var(--text-primary)]">비밀번호 찾기</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-[var(--text-muted)] mb-5">이메일 인증 후 새 비밀번호를 설정하세요.</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<EnvelopeIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type="email"
|
||||||
|
placeholder="이메일을 입력해주세요."
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value)
|
||||||
|
setCodeSent(false)
|
||||||
|
setSuccess('')
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="overflow-hidden transition-all duration-300 ease-in-out"
|
||||||
|
style={{ maxHeight: email.length > 0 ? (codeSent ? '238px' : '46px') : '0', opacity: email.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="login-input-wrap flex-1">
|
||||||
|
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type="text"
|
||||||
|
placeholder="인증코드 6자리"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
maxLength={6}
|
||||||
|
required={codeSent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={sendingCode || codeTimer > 0 || !email || Boolean(emailError)}
|
||||||
|
onClick={handleSendCode}
|
||||||
|
className="verify-code-btn"
|
||||||
|
>
|
||||||
|
{sendingCode
|
||||||
|
? '발송 중...'
|
||||||
|
: codeTimer > 0
|
||||||
|
? `재발송 (${codeTimer}s)`
|
||||||
|
: codeSent ? '재발송' : '인증코드 발송'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="transition-all duration-300 overflow-hidden text-[11px] leading-none"
|
||||||
|
style={{ maxHeight: codeSent ? '20px' : '0', opacity: codeSent ? 1 : 0, color: 'rgba(59,200,130,0.85)' }}
|
||||||
|
>
|
||||||
|
인증코드가 이메일로 발송되었습니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<LockIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="새 비밀번호"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required={codeSent}
|
||||||
|
/>
|
||||||
|
<EyeToggle show={showPassword} onClick={() => setShowPassword((value) => !value)} />
|
||||||
|
</div>
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<LockIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type={showPasswordConfirm ? 'text' : 'password'}
|
||||||
|
placeholder="새 비밀번호 확인"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
|
required={codeSent}
|
||||||
|
/>
|
||||||
|
<EyeToggle show={showPasswordConfirm} onClick={() => setShowPasswordConfirm((value) => !value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && <p className="mb-4 text-center text-[12px] text-[rgba(59,200,130,0.9)]">{success}</p>}
|
||||||
|
{error && <p className="mb-4 text-center text-[12px] text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" className="login-btn w-full mb-5" disabled={submitting || !codeSent} style={{ opacity: submitting || !codeSent ? 0.7 : 1 }}>
|
||||||
|
{submitting ? '변경 중...' : '비밀번호 변경'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-[12px] text-[var(--text-muted)]">
|
||||||
|
비밀번호가 기억나셨나요?{' '}
|
||||||
|
<button type="button" onClick={onGoLogin} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PasswordResetForm
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { EnvelopeIcon, LockIcon, PersonIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||||
|
import type { AuthSession } from './LoginForm'
|
||||||
|
|
||||||
|
interface SignupFormProps {
|
||||||
|
onGoLogin: () => void
|
||||||
|
onSignupComplete: (session: AuthSession) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_BASE_URL = (
|
||||||
|
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
|
||||||
|
).replace(/\/+$/, '')
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||||
|
|
||||||
|
function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX.Element {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [signupEmail, setSignupEmail] = useState('')
|
||||||
|
const [signupPassword, setSignupPassword] = useState('')
|
||||||
|
const [signupConfirm, setSignupConfirm] = useState('')
|
||||||
|
const [showSignupPw, setShowSignupPw] = useState(false)
|
||||||
|
const [showSignupConfirm, setShowSignupConfirm] = useState(false)
|
||||||
|
const [agreeTerms, setAgreeTerms] = useState(false)
|
||||||
|
const [agreePrivacy, setAgreePrivacy] = useState(false)
|
||||||
|
const [verifyCode, setVerifyCode] = useState('')
|
||||||
|
const [codeSent, setCodeSent] = useState(false)
|
||||||
|
const [verificationEmail, setVerificationEmail] = useState('')
|
||||||
|
const [sendingCode, setSendingCode] = useState(false)
|
||||||
|
const [codeTimer, setCodeTimer] = useState(0)
|
||||||
|
const [promoCode, setPromoCode] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const emailError = signupEmail.length > 0 && !EMAIL_PATTERN.test(signupEmail) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||||
|
const normalizedSignupEmail = signupEmail.trim().toLowerCase()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (codeTimer <= 0) return
|
||||||
|
const t = setTimeout(() => setCodeTimer((v) => v - 1), 1000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [codeTimer])
|
||||||
|
|
||||||
|
const handleSendCode = async (): Promise<void> => {
|
||||||
|
if (emailError) return
|
||||||
|
setSendingCode(true)
|
||||||
|
setVerifyCode('')
|
||||||
|
setVerificationEmail('')
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AUTH_BASE_URL}/auth/email-verification/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ email: signupEmail }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
throw new Error('이미 가입된 이메일입니다.')
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('인증코드 발송에 실패했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
setCodeSent(true)
|
||||||
|
setVerificationEmail(normalizedSignupEmail)
|
||||||
|
setCodeTimer(60)
|
||||||
|
} catch (sendError) {
|
||||||
|
setCodeSent(false)
|
||||||
|
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setSendingCode(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (emailError) {
|
||||||
|
setError('올바른 이메일 형식으로 입력해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (signupPassword.length < 8) {
|
||||||
|
setError('비밀번호는 8자 이상이어야 합니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (signupPassword !== signupConfirm) {
|
||||||
|
setError('비밀번호가 일치하지 않습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!agreeTerms || !agreePrivacy) {
|
||||||
|
setError('필수 약관에 동의해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^\d{6}$/.test(verifyCode)) {
|
||||||
|
setError('이메일 인증코드 6자리를 입력해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!codeSent || verificationEmail !== normalizedSignupEmail) {
|
||||||
|
setError('현재 이메일로 인증코드를 다시 발송해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AUTH_BASE_URL}/auth/signup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
email: signupEmail,
|
||||||
|
password: signupPassword,
|
||||||
|
verificationCode: verifyCode,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
throw new Error('이미 가입된 이메일입니다.')
|
||||||
|
}
|
||||||
|
if (response.status === 400) {
|
||||||
|
throw new Error('인증코드가 올바르지 않거나 만료되었습니다.')
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('회원가입에 실패했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await response.json() as AuthSession
|
||||||
|
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
|
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||||
|
onSignupComplete(session)
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : '회원가입에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onGoLogin}
|
||||||
|
className="flex items-center justify-center w-6 h-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors duration-200 cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M15 18l-6-6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 className="text-[20px] font-bold text-[var(--text-primary)]">회원가입</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-[var(--text-muted)] mb-5">새 계정을 만들어 서비스를 이용해보세요.</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<PersonIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type="text"
|
||||||
|
placeholder="이름을 입력해주세요."
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<EnvelopeIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type="email"
|
||||||
|
placeholder="이메일을 입력해주세요."
|
||||||
|
value={signupEmail}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextEmail = e.target.value
|
||||||
|
const nextNormalizedEmail = nextEmail.trim().toLowerCase()
|
||||||
|
setSignupEmail(nextEmail)
|
||||||
|
if (verificationEmail && nextNormalizedEmail !== verificationEmail) {
|
||||||
|
setCodeSent(false)
|
||||||
|
setVerifyCode('')
|
||||||
|
setCodeTimer(0)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인증코드 — 이메일 입력 시 노출 */}
|
||||||
|
<div
|
||||||
|
className="overflow-hidden transition-all duration-300 ease-in-out"
|
||||||
|
style={{ maxHeight: signupEmail.length > 0 ? (codeSent ? '78px' : '46px') : '0', opacity: signupEmail.length > 0 ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="login-input-wrap flex-1">
|
||||||
|
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type="text"
|
||||||
|
placeholder="인증코드 6자리"
|
||||||
|
value={verifyCode}
|
||||||
|
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={sendingCode || codeTimer > 0 || !signupEmail || Boolean(emailError)}
|
||||||
|
onClick={handleSendCode}
|
||||||
|
className="verify-code-btn"
|
||||||
|
>
|
||||||
|
{sendingCode
|
||||||
|
? '발송 중...'
|
||||||
|
: codeTimer > 0
|
||||||
|
? `재발송 (${codeTimer}s)`
|
||||||
|
: codeSent ? '재발송' : '인증코드 발송'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 발송 완료 안내 */}
|
||||||
|
<div
|
||||||
|
className="transition-all duration-300 overflow-hidden text-[11px] leading-none"
|
||||||
|
style={{ maxHeight: codeSent ? '20px' : '0', opacity: codeSent ? 1 : 0, color: 'rgba(59,200,130,0.85)' }}
|
||||||
|
>
|
||||||
|
인증코드가 이메일로 발송되었습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<LockIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type={showSignupPw ? 'text' : 'password'}
|
||||||
|
placeholder="비밀번호를 입력해주세요."
|
||||||
|
value={signupPassword}
|
||||||
|
onChange={(e) => setSignupPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<EyeToggle show={showSignupPw} onClick={() => setShowSignupPw((v) => !v)} />
|
||||||
|
</div>
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<LockIcon />
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type={showSignupConfirm ? 'text' : 'password'}
|
||||||
|
placeholder="비밀번호를 다시 입력해주세요."
|
||||||
|
value={signupConfirm}
|
||||||
|
onChange={(e) => setSignupConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<EyeToggle show={showSignupConfirm} onClick={() => setShowSignupConfirm((v) => !v)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로모션 코드 (선택) */}
|
||||||
|
<div className="login-input-wrap">
|
||||||
|
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
|
||||||
|
<line x1="7" y1="7" x2="7.01" y2="7"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="login-input-field"
|
||||||
|
type="text"
|
||||||
|
placeholder="프로모션 코드 (선택)"
|
||||||
|
value={promoCode}
|
||||||
|
onChange={(e) => setPromoCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 약관 동의 */}
|
||||||
|
<div className="flex flex-col gap-[10px] mb-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={agreeTerms} onChange={(e) => setAgreeTerms(e.target.checked)} className="login-checkbox" required />
|
||||||
|
<span className="text-[12px] text-[var(--text-secondary)]">
|
||||||
|
이용약관에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={agreePrivacy} onChange={(e) => setAgreePrivacy(e.target.checked)} className="login-checkbox" required />
|
||||||
|
<span className="text-[12px] text-[var(--text-secondary)]">
|
||||||
|
개인정보 처리방침에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-4 text-center text-[12px] text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" className="login-btn w-full mb-4" disabled={submitting} style={{ opacity: submitting ? 0.7 : 1 }}>
|
||||||
|
{submitting ? '가입 중...' : '회원가입'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||||
|
<span className="text-[11px] text-[var(--text-muted)]">또는</span>
|
||||||
|
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="login-google-btn w-full mb-5">
|
||||||
|
<GoogleIcon />
|
||||||
|
Google로 회원가입
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-[12px] text-[var(--text-muted)]">
|
||||||
|
이미 계정이 있나요?{' '}
|
||||||
|
<button type="button" onClick={onGoLogin} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignupForm
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.web.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
|
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["electron-vite/node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
|
"include": ["src/renderer/src/**/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"paths": {
|
||||||
|
"@renderer/*": ["./src/renderer/src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||