init studio launcher frontend

This commit is contained in:
2026-07-02 11:20:40 +09:00
commit a294b724b4
93 changed files with 17158 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
out/
.env
.env.*
*.log
.DS_Store
+61
View File
@@ -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/` 아래에 생성된다.
+28
View File
@@ -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()]
}
})
+10862
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long
@@ -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}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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=>{}}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long
+24
View File
@@ -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"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+1
View File
@@ -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

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -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

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -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"}
+12
View File
@@ -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"}
+2
View File
@@ -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"}
File diff suppressed because one or more lines are too long
@@ -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"}
+1
View File
@@ -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

File diff suppressed because one or more lines are too long
+28
View File
@@ -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",{}]]
+28
View File
@@ -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",{}]]
+6
View File
@@ -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"}
+12
View File
@@ -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"}
+2
View File
@@ -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
+5
View File
@@ -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"}
+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

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

After

Width:  |  Height:  |  Size: 52 KiB

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