Add client apply flow and asset uploads

This commit is contained in:
2026-05-08 20:03:07 +09:00
parent 427b708277
commit 4453dbd8f3
13 changed files with 730 additions and 73 deletions

View File

@@ -17,7 +17,8 @@
<li data-step="4">다운로드 및 설치</li> <li data-step="4">다운로드 및 설치</li>
<li data-step="5">서버 설정</li> <li data-step="5">서버 설정</li>
<li data-step="6">포트포워딩 설정</li> <li data-step="6">포트포워딩 설정</li>
<li data-step="7">완료</li> <li data-step="7">클라이언트 적용</li>
<li data-step="8">완료</li>
</ol> </ol>
</aside> </aside>
@@ -115,6 +116,19 @@
<section class="panel" data-panel="7"> <section class="panel" data-panel="7">
<p class="eyebrow">STEP 7</p> <p class="eyebrow">STEP 7</p>
<h2>클라이언트 적용</h2>
<p class="infoHint">런처 프로필, 로더 설치, 리소스팩, 쉐이더를 한 번에 적용합니다.</p>
<div class="buttonRow">
<button id="applyClientButton" class="primary">클라이언트 적용</button>
</div>
<div id="clientApplyStatus" class="infoBox"></div>
<div class="buttonRow between">
<button data-back="6">이전</button>
</div>
</section>
<section class="panel" data-panel="8">
<p class="eyebrow">STEP 8</p>
<h2>완료</h2> <h2>완료</h2>
<label class="toggleRow"> <label class="toggleRow">
<input type="checkbox" id="createShortcutToggle" checked /> <input type="checkbox" id="createShortcutToggle" checked />

View File

@@ -19,6 +19,8 @@ const eulaBlock = document.getElementById('eulaBlock')
const eulaText = document.getElementById('eulaText') const eulaText = document.getElementById('eulaText')
const eulaLink = document.getElementById('eulaLink') const eulaLink = document.getElementById('eulaLink')
const startInstallButton = document.getElementById('startInstallButton') const startInstallButton = document.getElementById('startInstallButton')
const applyClientButton = document.getElementById('applyClientButton')
const clientApplyStatus = document.getElementById('clientApplyStatus')
function setActiveStep(step) { function setActiveStep(step) {
for (const [key, panel] of panelMap.entries()) { for (const [key, panel] of panelMap.entries()) {
@@ -275,6 +277,18 @@ document.getElementById('toStep7').addEventListener('click', async () => {
await goToStep(7) await goToStep(7)
}) })
applyClientButton.addEventListener('click', async () => {
applyClientButton.disabled = true
clientApplyStatus.textContent = '클라이언트 적용 중입니다.'
try {
const result = await window.installerApi.applyClient()
clientApplyStatus.textContent = result.message
await goToStep(result.nextStep)
} finally {
applyClientButton.disabled = false
}
})
document.getElementById('openFolderButton').addEventListener('click', async () => { document.getElementById('openFolderButton').addEventListener('click', async () => {
await window.installerApi.openFolder() await window.installerApi.openFolder()
}) })

View File

@@ -1,6 +1,9 @@
{ {
"mcVersion": "1.20.1", "mcVersion": "1.20.1",
"recommendedJdkVersion": 17, "recommendedJdkVersion": 17,
"loaderType": "vanilla",
"loaderVersion": "",
"loaderInstallerPath": "",
"serverMinRam": 2048, "serverMinRam": 2048,
"serverMaxRam": 4096, "serverMaxRam": 4096,
"clientMinRam": 4096, "clientMinRam": 4096,
@@ -10,5 +13,7 @@
"configEditableFiles": [ "configEditableFiles": [
"server.properties", "server.properties",
"bukkit.yml" "bukkit.yml"
] ],
"resourcePackFiles": [],
"shaderPackFiles": []
} }

158
package-lock.json generated
View File

@@ -8,10 +8,12 @@
"name": "mc-custom-suite", "name": "mc-custom-suite",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@types/multer": "^2.1.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"multer": "^2.1.1",
"nat-upnp": "^1.1.1" "nat-upnp": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
@@ -563,7 +565,6 @@
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
"@types/node": "*" "@types/node": "*"
@@ -585,7 +586,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -609,7 +609,6 @@
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0", "@types/express-serve-static-core": "^5.0.0",
@@ -620,7 +619,6 @@
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"@types/qs": "*", "@types/qs": "*",
@@ -655,8 +653,7 @@
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="
"dev": true
}, },
"node_modules/@types/keyv": { "node_modules/@types/keyv": {
"version": "3.1.4", "version": "3.1.4",
@@ -673,11 +670,18 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true "dev": true
}, },
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.12.2", "version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"dev": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -696,14 +700,12 @@
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="
"dev": true
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="
"dev": true
}, },
"node_modules/@types/responselike": { "node_modules/@types/responselike": {
"version": "1.0.3", "version": "1.0.3",
@@ -718,7 +720,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -727,7 +728,6 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
"@types/node": "*" "@types/node": "*"
@@ -1014,6 +1014,11 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1219,8 +1224,7 @@
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
"dev": true
}, },
"node_modules/builder-util": { "node_modules/builder-util": {
"version": "26.8.1", "version": "26.8.1",
@@ -1294,6 +1298,17 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1503,6 +1518,20 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true "dev": true
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -3313,6 +3342,63 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nat-upnp": { "node_modules/nat-upnp": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/nat-upnp/-/nat-upnp-1.1.1.tgz", "resolved": "https://registry.npmjs.org/nat-upnp/-/nat-upnp-1.1.1.tgz",
@@ -3844,6 +3930,19 @@
"read-binary-file-arch": "cli.js" "read-binary-file-arch": "cli.js"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/request": { "node_modules/request": {
"version": "2.88.2", "version": "2.88.2",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
@@ -4348,6 +4447,22 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -4605,6 +4720,11 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -4641,8 +4761,7 @@
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
"dev": true
}, },
"node_modules/universalify": { "node_modules/universalify": {
"version": "0.1.2", "version": "0.1.2",
@@ -4675,6 +4794,11 @@
"integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==",
"dev": true "dev": true
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",

View File

@@ -11,10 +11,12 @@
"smoke": "npm run build && node -e \"require('./dist/shared/store').ensureProjectFiles(); console.log('smoke ok')\"" "smoke": "npm run build && node -e \"require('./dist/shared/store').ensureProjectFiles(); console.log('smoke ok')\""
}, },
"dependencies": { "dependencies": {
"@types/multer": "^2.1.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.2", "express-session": "^1.18.2",
"multer": "^2.1.1",
"nat-upnp": "^1.1.1" "nat-upnp": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -281,6 +281,10 @@ button {
gap: 18px; gap: 18px;
} }
.fullSpan {
grid-column: 1 / -1;
}
input, input,
textarea, textarea,
select { select {
@@ -298,6 +302,49 @@ textarea {
resize: vertical; resize: vertical;
} }
.assetSection {
display: grid;
gap: 18px;
margin-top: 24px;
}
.assetCard {
display: grid;
gap: 14px;
background: rgba(21, 29, 25, 0.72);
border: 1px solid var(--line);
border-radius: 22px;
padding: 22px;
}
.assetCard h2 {
margin: 0;
font-size: 24px;
}
.assetForm {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.assetList {
display: grid;
gap: 10px;
}
.assetItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: var(--panel-strong);
}
.errorText { .errorText {
color: #ffb7ae; color: #ffb7ae;
margin: 0; margin: 0;

View File

@@ -44,6 +44,14 @@ function resolveJavaExecutable(jdkPath: string): string {
: path.join(jdkPath, 'bin', 'java') : path.join(jdkPath, 'bin', 'java')
} }
function getLauncherRootDir(): string {
const appData = process.env.APPDATA
if (process.platform === 'win32' && appData != null) {
return path.join(appData, '.minecraft')
}
return path.join(os.homedir(), '.minecraft')
}
function parseJavaMajorVersion(rawVersion: string): number | null { function parseJavaMajorVersion(rawVersion: string): number | null {
const cleaned = rawVersion.trim().replace(/^"+|"+$/g, '') const cleaned = rawVersion.trim().replace(/^"+|"+$/g, '')
if (cleaned.length === 0) { if (cleaned.length === 0) {
@@ -255,13 +263,9 @@ function resolveClientRamMb(pack: PackDefinition): { selected: number; warning:
throw new Error('플레이 불가: 시스템 램이 최소 램보다 적습니다.') throw new Error('플레이 불가: 시스템 램이 최소 램보다 적습니다.')
} }
async function writeLauncherProfile(packName: string, installRoot: string, pack: PackDefinition): Promise<void> { async function writeLauncherProfile(packName: string, installRoot: string, pack: PackDefinition, lastVersionId: string): Promise<void> {
const appData = process.env.APPDATA const launcherRoot = getLauncherRootDir()
if (appData == null) { const launcherProfilesPath = path.join(launcherRoot, 'launcher_profiles.json')
return
}
const launcherProfilesPath = path.join(appData, '.minecraft', 'launcher_profiles.json')
const gameDir = path.join(installRoot, '.mc_custom') const gameDir = path.join(installRoot, '.mc_custom')
const selectedRam = resolveClientRamMb(pack).selected const selectedRam = resolveClientRamMb(pack).selected
@@ -275,15 +279,17 @@ async function writeLauncherProfile(packName: string, installRoot: string, pack:
} }
const profiles = typeof payload.profiles === 'object' && payload.profiles != null const profiles = typeof payload.profiles === 'object' && payload.profiles != null
? payload.profiles as Record<string, unknown> ? payload.profiles as Record<string, Record<string, unknown>>
: {} : {}
profiles[packName] = { const existingKey = Object.entries(profiles).find(([, profile]) => profile.name === packName)?.[0] ?? packName
created: new Date().toISOString(), profiles[existingKey] = {
...(profiles[existingKey] ?? {}),
created: String(profiles[existingKey]?.created ?? new Date().toISOString()),
gameDir, gameDir,
icon: 'Grass', icon: 'Grass',
javaArgs: `-Xms${Math.min(selectedRam, 2048)}M -Xmx${selectedRam}M`, javaArgs: `-Xms${Math.min(selectedRam, 2048)}M -Xmx${selectedRam}M`,
lastVersionId: pack.mcVersion, lastVersionId,
name: packName, name: packName,
type: 'custom' type: 'custom'
} }
@@ -343,7 +349,6 @@ async function ensureEditableConfigFiles(root: string, pack: PackDefinition): Pr
await fsp.mkdir(path.dirname(targetPath), { recursive: true }) await fsp.mkdir(path.dirname(targetPath), { recursive: true })
const baseName = path.basename(relativeFile) const baseName = path.basename(relativeFile)
if (baseName === 'server.properties') { if (baseName === 'server.properties') {
await fsp.writeFile(targetPath, [ await fsp.writeFile(targetPath, [
'motd=A Minecraft Server', 'motd=A Minecraft Server',
@@ -365,6 +370,41 @@ async function ensureEditableConfigFiles(root: string, pack: PackDefinition): Pr
} }
} }
async function listFilesRecursively(root: string, predicate: (entryPath: string, entryName: string) => boolean): Promise<string[]> {
const results: string[] = []
const entries = await fsp.readdir(root, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(root, entry.name)
if (entry.isDirectory()) {
results.push(...await listFilesRecursively(entryPath, predicate))
continue
}
if (predicate(entryPath, entry.name)) {
results.push(entryPath)
}
}
return results
}
async function patchRunBatFiles(root: string, pack: PackDefinition): Promise<void> {
const runBatFiles = await listFilesRecursively(root, (_entryPath, entryName) => entryName.toLowerCase() === 'run.bat')
for (const runBatPath of runBatFiles) {
const raw = await fsp.readFile(runBatPath, 'utf8')
let next = raw
.replace(/-Xms\S+/gi, `-Xms${pack.serverMinRam}M`)
.replace(/-Xmx\S+/gi, `-Xmx${pack.serverMaxRam}M`)
if (next === raw && /java(\.exe)?/i.test(raw)) {
next = raw.replace(/java(\.exe)?/i, (match) => `${match} -Xms${pack.serverMinRam}M -Xmx${pack.serverMaxRam}M`)
}
if (next !== raw) {
await fsp.writeFile(runBatPath, next, 'utf8')
sendLog(`run.bat 램 설정 반영: ${path.relative(root, runBatPath)}`)
}
}
}
function stripHtmlToText(html: string): string { function stripHtmlToText(html: string): string {
return html return html
.replace(/<script[\s\S]*?<\/script>/gi, ' ') .replace(/<script[\s\S]*?<\/script>/gi, ' ')
@@ -391,14 +431,14 @@ async function loadMinecraftEulaText(): Promise<string> {
return plainText.slice(0, 7000) return plainText.slice(0, 7000)
} }
} catch { } catch {
// Fall back to the bundled summary below. // Fall back below.
} }
return [ return [
'Minecraft EULA 요약', 'Minecraft EULA 요약',
'', '',
'이 설치기는 공식 Minecraft EULA 동의를 받아야만 서버팩 설치를 계속할 수 있습니다.', '이 설치기는 공식 Minecraft EULA 동의를 받아야만 서버팩 설치를 계속할 수 있습니다.',
'상업적 이용, 계정 공유, 저작권 침해 등은 허용되지 않으며, 원문은 아래 주소에서 확인할 수 있습니다.', '원문은 아래 주소에서 확인할 수 있습니다.',
'', '',
MINECRAFT_EULA_URL MINECRAFT_EULA_URL
].join('\n') ].join('\n')
@@ -441,6 +481,8 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath) const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath)
await ensureEditableConfigFiles(extractedRoot, packMeta.packDefinition) await ensureEditableConfigFiles(extractedRoot, packMeta.packDefinition)
await patchRunBatFiles(extractedRoot, packMeta.packDefinition)
const eulaPath = path.join(extractedRoot, 'eula.txt') const eulaPath = path.join(extractedRoot, 'eula.txt')
if (fs.existsSync(eulaPath)) { if (fs.existsSync(eulaPath)) {
await fsp.unlink(eulaPath) await fsp.unlink(eulaPath)
@@ -448,6 +490,7 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
currentInstall = { currentInstall = {
manifestUrl: payload.manifestUrl, manifestUrl: payload.manifestUrl,
baseUrl: packMeta.baseUrl,
packFile: payload.packFile, packFile: payload.packFile,
installPath: payload.installPath, installPath: payload.installPath,
jdkPath: payload.jdkPath, jdkPath: payload.jdkPath,
@@ -459,8 +502,6 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
await waitForEulaAcceptance() await waitForEulaAcceptance()
await fsp.writeFile(eulaPath, 'eula=true\n', 'utf8') await fsp.writeFile(eulaPath, 'eula=true\n', 'utf8')
sendLog('EULA 동의 반영 완료', 'success') sendLog('EULA 동의 반영 완료', 'success')
await writeLauncherProfile(packMeta.packName, payload.installPath, packMeta.packDefinition)
sendLog('Minecraft 런처 프로필 추가 완료', 'success')
return { return {
nextStep: 5, nextStep: 5,
@@ -608,7 +649,7 @@ function renderConfigEditorPage(): string {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>서버 설정 편집기</title> <title>서버 설정 편집기</title>
<style> <style>
:root{color-scheme:dark;--bg:#0f1411;--panel:#171e1a;--soft:#202924;--line:#2d3932;--text:#f3f5f4;--muted:#abb5af;--accent:#f0bf57;--ok:#8cd98c;} :root{color-scheme:dark;--panel:#171e1a;--soft:#202924;--line:#2d3932;--text:#f3f5f4;--muted:#abb5af;--ok:#8cd98c;}
*{box-sizing:border-box;} body{margin:0;font-family:"Segoe UI",sans-serif;background:linear-gradient(180deg,#0b100d 0%,#111813 100%);color:var(--text);} *{box-sizing:border-box;} body{margin:0;font-family:"Segoe UI",sans-serif;background:linear-gradient(180deg,#0b100d 0%,#111813 100%);color:var(--text);}
.shell{display:grid;grid-template-columns:340px 1fr;min-height:100vh;} .shell{display:grid;grid-template-columns:340px 1fr;min-height:100vh;}
.sidebar{padding:24px;border-right:1px solid var(--line);background:rgba(12,17,14,0.86);} .sidebar{padding:24px;border-right:1px solid var(--line);background:rgba(12,17,14,0.86);}
@@ -655,12 +696,7 @@ function renderConfigEditorPage(): string {
</main> </main>
</div> </div>
<script> <script>
const state = { const state = { directory: '', selectedFile: '', saveTimer: null }
directory: '',
selectedFile: '',
saveTimer: null
}
const entryList = document.getElementById('entryList') const entryList = document.getElementById('entryList')
const currentPath = document.getElementById('currentPath') const currentPath = document.getElementById('currentPath')
const crumbs = document.getElementById('crumbs') const crumbs = document.getElementById('crumbs')
@@ -674,9 +710,7 @@ function renderConfigEditorPage(): string {
async function loadDirectory(relativePath = '') { async function loadDirectory(relativePath = '') {
const response = await fetch('/api/list?path=' + encodeURIComponent(relativePath)) const response = await fetch('/api/list?path=' + encodeURIComponent(relativePath))
if (!response.ok) { if (!response.ok) throw new Error('목록을 불러오지 못했습니다.')
throw new Error('목록을 불러오지 못했습니다.')
}
const payload = await response.json() const payload = await response.json()
state.directory = payload.relativePath state.directory = payload.relativePath
currentPath.textContent = payload.relativePath || '루트' currentPath.textContent = payload.relativePath || '루트'
@@ -703,9 +737,7 @@ function renderConfigEditorPage(): string {
async function loadFile(relativePath) { async function loadFile(relativePath) {
const response = await fetch('/api/file?path=' + encodeURIComponent(relativePath)) const response = await fetch('/api/file?path=' + encodeURIComponent(relativePath))
if (!response.ok) { if (!response.ok) throw new Error('파일을 열지 못했습니다.')
throw new Error('파일을 열지 못했습니다.')
}
const payload = await response.json() const payload = await response.json()
state.selectedFile = payload.relativePath state.selectedFile = payload.relativePath
currentPath.textContent = payload.relativePath currentPath.textContent = payload.relativePath
@@ -722,24 +754,17 @@ function renderConfigEditorPage(): string {
textarea.value = payload.content textarea.value = payload.content
textarea.addEventListener('input', () => { textarea.addEventListener('input', () => {
setStatus('변경 감지됨. 자동 저장 중...') setStatus('변경 감지됨. 자동 저장 중...')
if (state.saveTimer != null) { if (state.saveTimer != null) clearTimeout(state.saveTimer)
clearTimeout(state.saveTimer)
}
state.saveTimer = setTimeout(async () => { state.saveTimer = setTimeout(async () => {
const saveResponse = await fetch('/api/file', { const saveResponse = await fetch('/api/file', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ path: state.selectedFile, content: textarea.value })
path: state.selectedFile,
content: textarea.value
}) })
})
if (!saveResponse.ok) { if (!saveResponse.ok) {
setStatus('저장 실패', 'error') setStatus('저장 실패', 'error')
return return
} }
setStatus('저장 완료', 'ok') setStatus('저장 완료', 'ok')
}, 300) }, 300)
}) })
@@ -752,11 +777,7 @@ function renderConfigEditorPage(): string {
}) })
document.getElementById('upButton').addEventListener('click', () => { document.getElementById('upButton').addEventListener('click', () => {
if (!state.directory) { const next = state.directory ? state.directory.split('/').slice(0, -1).join('/') : ''
loadDirectory('').catch((error) => setStatus(error.message, 'error'))
return
}
const next = state.directory.split('/').slice(0, -1).join('/')
loadDirectory(next).catch((error) => setStatus(error.message, 'error')) loadDirectory(next).catch((error) => setStatus(error.message, 'error'))
}) })
@@ -902,14 +923,177 @@ async function openInstalledFolder(): Promise<void> {
await shell.openPath(currentInstall.installPath) await shell.openPath(currentInstall.installPath)
} }
async function findServerLaunchScript(root: string): Promise<string | null> {
const candidates = await listFilesRecursively(root, (_entryPath, entryName) => entryName.toLowerCase() === 'run.bat')
return candidates[0] ?? null
}
async function findServerJar(root: string): Promise<string | null> { async function findServerJar(root: string): Promise<string | null> {
const entries = await fsp.readdir(root, { withFileTypes: true }) const candidates = await listFilesRecursively(root, (_entryPath, entryName) => entryName.toLowerCase().endsWith('.jar'))
for (const entry of entries) { return candidates[0] ?? null
if (entry.isFile() && entry.name.endsWith('.jar')) { }
return path.join(root, entry.name)
async function downloadSiteFile(baseUrl: string, relativePath: string, targetDir: string): Promise<string> {
const normalizedRelativePath = relativePath.replace(/^\/+/, '')
const targetUrl = new URL(`/file/${normalizedRelativePath}`, baseUrl).toString()
const fileName = path.basename(normalizedRelativePath)
const targetPath = path.join(targetDir, fileName)
await fsp.mkdir(targetDir, { recursive: true })
const response = await fetch(targetUrl)
if (!response.ok) {
throw new Error(`파일 다운로드 실패: ${normalizedRelativePath}`)
} }
const arrayBuffer = await response.arrayBuffer()
await fsp.writeFile(targetPath, Buffer.from(arrayBuffer))
return targetPath
}
async function listVersionDirectories(launcherRoot: string): Promise<string[]> {
const versionsDir = path.join(launcherRoot, 'versions')
if (!fs.existsSync(versionsDir)) {
return []
} }
const entries = await fsp.readdir(versionsDir, { withFileTypes: true })
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
}
async function pickNewestMatchingVersion(launcherRoot: string, matchers: string[]): Promise<string | null> {
const versionsDir = path.join(launcherRoot, 'versions')
if (!fs.existsSync(versionsDir)) {
return null return null
}
const entries = await fsp.readdir(versionsDir, { withFileTypes: true })
const matched = await Promise.all(
entries
.filter((entry) => entry.isDirectory() && matchers.every((matcher) => entry.name.toLowerCase().includes(matcher.toLowerCase())))
.map(async (entry) => ({
name: entry.name,
mtimeMs: (await fsp.stat(path.join(versionsDir, entry.name))).mtimeMs
}))
)
matched.sort((left, right) => right.mtimeMs - left.mtimeMs)
return matched[0]?.name ?? null
}
async function resolveInstalledVersionId(launcherRoot: string, before: string[], pack: PackDefinition): Promise<string> {
const after = await listVersionDirectories(launcherRoot)
const added = after.filter((entry) => !before.includes(entry))
if (added.length === 1) {
return added[0]
}
if (added.length > 1) {
return added[added.length - 1]
}
const loaderType = pack.loaderType ?? 'vanilla'
if (loaderType === 'vanilla') {
return pack.mcVersion
}
if (loaderType === 'fabric' && pack.loaderVersion != null && pack.loaderVersion.length > 0) {
const candidate = `fabric-loader-${pack.loaderVersion}-${pack.mcVersion}`
if (after.includes(candidate)) {
return candidate
}
}
const matchers = [loaderType, pack.mcVersion]
if (pack.loaderVersion != null && pack.loaderVersion.length > 0) {
matchers.push(pack.loaderVersion)
}
const fallback = await pickNewestMatchingVersion(launcherRoot, matchers)
if (fallback != null) {
return fallback
}
throw new Error(`${loaderType} 클라이언트 버전 폴더를 찾지 못했습니다.`)
}
async function installClientLoader(): Promise<string> {
if (currentInstall == null) {
throw new Error('설치된 서버팩 정보가 없습니다.')
}
const pack = currentInstall.packDefinition
const loaderType = pack.loaderType ?? 'vanilla'
if (loaderType === 'vanilla') {
return pack.mcVersion
}
if (pack.loaderInstallerPath == null || pack.loaderInstallerPath.length === 0) {
throw new Error('클라이언트 로더 설치파일 경로가 비어 있습니다.')
}
const launcherRoot = getLauncherRootDir()
const cacheDir = path.join(currentInstall.extractedRoot, '.client-cache')
const installerPath = await downloadSiteFile(currentInstall.baseUrl, pack.loaderInstallerPath, cacheDir)
const beforeVersions = await listVersionDirectories(launcherRoot)
const javaExec = resolveJavaExecutable(currentInstall.jdkPath)
if (loaderType === 'fabric') {
if (pack.loaderVersion == null || pack.loaderVersion.length === 0) {
throw new Error('Fabric 로더 버전을 입력해야 합니다.')
}
sendLog(`Fabric 로더 설치 시작: ${pack.loaderVersion}`)
await execFileAsync(javaExec, [
'-jar',
installerPath,
'client',
'-dir',
launcherRoot,
'-mcversion',
pack.mcVersion,
'-loader',
pack.loaderVersion
])
return resolveInstalledVersionId(launcherRoot, beforeVersions, pack)
}
if (loaderType === 'forge') {
sendLog('Forge는 headless client install을 지원하지 않아 GUI 설치기를 실행합니다.', 'warn')
await execFileAsync(javaExec, ['-jar', installerPath])
return resolveInstalledVersionId(launcherRoot, beforeVersions, pack)
}
if (loaderType === 'neoforge') {
sendLog('NeoForge는 GUI 설치기를 실행합니다.', 'warn')
await execFileAsync(javaExec, ['-jar', installerPath])
return resolveInstalledVersionId(launcherRoot, beforeVersions, pack)
}
return pack.mcVersion
}
async function applyAssetGroup(relativePaths: string[] | undefined, targetDir: string, label: string): Promise<void> {
if (currentInstall == null || relativePaths == null || relativePaths.length === 0) {
return
}
await fsp.mkdir(targetDir, { recursive: true })
for (const relativePath of relativePaths) {
const downloaded = await downloadSiteFile(currentInstall.baseUrl, relativePath, targetDir)
sendLog(`${label} 적용: ${path.basename(downloaded)}`)
}
}
async function applyClientConfiguration(): Promise<{ nextStep: number; message: string }> {
if (currentInstall == null) {
throw new Error('설치된 서버팩 정보가 없습니다.')
}
sendLog('클라이언트 적용 시작')
const versionId = await installClientLoader()
await applyAssetGroup(currentInstall.packDefinition.resourcePackFiles, path.join(currentInstall.extractedRoot, 'resourcepacks'), '리소스팩')
await applyAssetGroup(currentInstall.packDefinition.shaderPackFiles, path.join(currentInstall.extractedRoot, 'shaderpacks'), '쉐이더')
await writeLauncherProfile(currentInstall.packName, currentInstall.installPath, currentInstall.packDefinition, versionId)
sendLog(`런처 프로필 적용 완료: ${versionId}`, 'success')
return {
nextStep: 8,
message: `클라이언트 적용 완료: ${versionId}`
}
} }
async function createDesktopShortcut(enabled: boolean): Promise<void> { async function createDesktopShortcut(enabled: boolean): Promise<void> {
@@ -917,8 +1101,20 @@ async function createDesktopShortcut(enabled: boolean): Promise<void> {
return return
} }
const desktopDir = path.join(os.homedir(), 'Desktop') const desktopDir = app.getPath('desktop')
await fsp.mkdir(desktopDir, { recursive: true })
const shortcutPath = path.join(desktopDir, `${currentInstall.packName} 서버 실행.cmd`) const shortcutPath = path.join(desktopDir, `${currentInstall.packName} 서버 실행.cmd`)
const launchScript = await findServerLaunchScript(currentInstall.extractedRoot)
if (launchScript != null) {
const contents = [
'@echo off',
`cd /d "${path.dirname(launchScript)}"`,
`call "${launchScript}"`
].join('\r\n')
await fsp.writeFile(shortcutPath, contents, 'utf8')
return
}
const serverJar = await findServerJar(currentInstall.extractedRoot) const serverJar = await findServerJar(currentInstall.extractedRoot)
if (serverJar == null) { if (serverJar == null) {
return return
@@ -926,10 +1122,9 @@ async function createDesktopShortcut(enabled: boolean): Promise<void> {
const contents = [ const contents = [
'@echo off', '@echo off',
`cd /d "${currentInstall.extractedRoot}"`, `cd /d "${path.dirname(serverJar)}"`,
`"${resolveJavaExecutable(currentInstall.jdkPath)}" -Xms${currentInstall.packDefinition.serverMinRam}M -Xmx${currentInstall.packDefinition.serverMaxRam}M -jar "${serverJar}" nogui` `"${resolveJavaExecutable(currentInstall.jdkPath)}" -Xms${currentInstall.packDefinition.serverMinRam}M -Xmx${currentInstall.packDefinition.serverMaxRam}M -jar "${serverJar}" nogui`
].join('\r\n') ].join('\r\n')
await fsp.writeFile(shortcutPath, contents, 'utf8') await fsp.writeFile(shortcutPath, contents, 'utf8')
} }
@@ -938,6 +1133,15 @@ async function runServer(enabled: boolean): Promise<void> {
return return
} }
const launchScript = await findServerLaunchScript(currentInstall.extractedRoot)
if (launchScript != null && process.platform === 'win32') {
execFile('cmd.exe', ['/c', launchScript], {
cwd: path.dirname(launchScript)
})
sendLog('run.bat 실행 시작', 'success')
return
}
const serverJar = await findServerJar(currentInstall.extractedRoot) const serverJar = await findServerJar(currentInstall.extractedRoot)
if (serverJar == null) { if (serverJar == null) {
sendLog('서버 JAR을 찾지 못해 자동 실행을 생략합니다.', 'warn') sendLog('서버 JAR을 찾지 못해 자동 실행을 생략합니다.', 'warn')
@@ -952,7 +1156,7 @@ async function runServer(enabled: boolean): Promise<void> {
serverJar, serverJar,
'nogui' 'nogui'
], { ], {
cwd: currentInstall.extractedRoot cwd: path.dirname(serverJar)
}) })
sendLog('서버 실행 시작', 'success') sendLog('서버 실행 시작', 'success')
} }
@@ -982,6 +1186,7 @@ function bindIpcHandlers() {
}) })
ipcMain.handle('installer:open-config-editor', async () => openConfigEditor()) ipcMain.handle('installer:open-config-editor', async () => openConfigEditor())
ipcMain.handle('installer:configure-port', async () => configurePort()) ipcMain.handle('installer:configure-port', async () => configurePort())
ipcMain.handle('installer:apply-client', async () => applyClientConfiguration())
ipcMain.handle('installer:open-folder', async () => openInstalledFolder()) ipcMain.handle('installer:open-folder', async () => openInstalledFolder())
ipcMain.handle('installer:create-shortcut', async (_event, enabled: boolean) => createDesktopShortcut(enabled)) ipcMain.handle('installer:create-shortcut', async (_event, enabled: boolean) => createDesktopShortcut(enabled))
ipcMain.handle('installer:run-server', async (_event, enabled: boolean) => runServer(enabled)) ipcMain.handle('installer:run-server', async (_event, enabled: boolean) => runServer(enabled))

View File

@@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld('installerApi', {
acceptEula: () => ipcRenderer.invoke('installer:accept-eula'), acceptEula: () => ipcRenderer.invoke('installer:accept-eula'),
openConfigEditor: () => ipcRenderer.invoke('installer:open-config-editor'), openConfigEditor: () => ipcRenderer.invoke('installer:open-config-editor'),
configurePort: () => ipcRenderer.invoke('installer:configure-port'), configurePort: () => ipcRenderer.invoke('installer:configure-port'),
applyClient: () => ipcRenderer.invoke('installer:apply-client'),
openFolder: () => ipcRenderer.invoke('installer:open-folder'), openFolder: () => ipcRenderer.invoke('installer:open-folder'),
createShortcut: (enabled: boolean) => ipcRenderer.invoke('installer:create-shortcut', enabled), createShortcut: (enabled: boolean) => ipcRenderer.invoke('installer:create-shortcut', enabled),
runServer: (enabled: boolean) => ipcRenderer.invoke('installer:run-server', enabled), runServer: (enabled: boolean) => ipcRenderer.invoke('installer:run-server', enabled),

View File

@@ -35,6 +35,7 @@ export interface InstallPayload {
export interface InstallSessionState { export interface InstallSessionState {
manifestUrl: string manifestUrl: string
baseUrl: string
packFile: string packFile: string
installPath: string installPath: string
jdkPath: string jdkPath: string

View File

@@ -1,5 +1,10 @@
import { Router } from 'express' import { Router } from 'express'
import multer from 'multer'
import path from 'node:path'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import { fetchReleaseVersions } from '../../shared/mojang' import { fetchReleaseVersions } from '../../shared/mojang'
import { fileDir } from '../../shared/paths'
import { import {
createNewPack, createNewPack,
deletePacks, deletePacks,
@@ -8,11 +13,14 @@ import {
loadPackDefinition, loadPackDefinition,
loadRootManifest, loadRootManifest,
normalizePackDefinition, normalizePackDefinition,
savePackDefinition,
updatePack updatePack
} from '../../shared/store' } from '../../shared/store'
import { PackDefinition } from '../../shared/types'
import { requireAuth } from '../middleware/auth' import { requireAuth } from '../middleware/auth'
export const opRouter = Router() export const opRouter = Router()
const upload = multer({ storage: multer.memoryStorage() })
function pickFirstValue(value: unknown): string { function pickFirstValue(value: unknown): string {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -21,6 +29,41 @@ function pickFirstValue(value: unknown): string {
return typeof value === 'string' ? value : '' return typeof value === 'string' ? value : ''
} }
function sanitizeUploadFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-')
}
function normalizeAssetPathForWeb(filePath: string): string {
return filePath.replace(/\\/g, '/')
}
async function saveUploadedPackAsset(packKey: string, bucket: 'loaders' | 'resourcepacks' | 'shaderpacks', file: Express.Multer.File): Promise<string> {
const safeName = sanitizeUploadFileName(file.originalname)
const relativePath = path.join('uploads', packKey, bucket, `${Date.now()}-${safeName}`)
const absolutePath = path.join(fileDir, relativePath)
await fsp.mkdir(path.dirname(absolutePath), { recursive: true })
await fsp.writeFile(absolutePath, file.buffer)
return normalizeAssetPathForWeb(relativePath)
}
async function mutatePackDefinition(packKey: string, mutate: (pack: PackDefinition) => void): Promise<void> {
const current = await loadPackDefinition(packKey)
if (current == null) {
throw new Error('서버팩 JSON을 찾을 수 없습니다.')
}
const next = normalizePackDefinition(current)
mutate(next)
await savePackDefinition(packKey, next)
}
async function removeUploadedAsset(relativePath: string): Promise<void> {
const absolutePath = path.join(fileDir, relativePath)
if (fs.existsSync(absolutePath)) {
await fsp.unlink(absolutePath)
}
}
opRouter.get('/op', (req, res) => { opRouter.get('/op', (req, res) => {
if (req.session.userId != null) { if (req.session.userId != null) {
res.redirect('/op/dashboard') res.redirect('/op/dashboard')
@@ -125,10 +168,18 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const packKey = pickFirstValue(req.params.packName) const packKey = pickFirstValue(req.params.packName)
const nextPackName = pickFirstValue(req.body.displayName).trim() || packKey const nextPackName = pickFirstValue(req.body.displayName).trim() || packKey
const nextJsonKey = pickFirstValue(req.body.fileName).trim() || packKey const nextJsonKey = pickFirstValue(req.body.fileName).trim() || packKey
const currentDefinition = await loadPackDefinition(packKey)
if (currentDefinition == null) {
throw new Error('서버팩 JSON을 찾을 수 없습니다.')
}
const normalized = normalizePackDefinition({ const normalized = normalizePackDefinition({
...currentDefinition,
mcVersion: pickFirstValue(req.body.mcVersion), mcVersion: pickFirstValue(req.body.mcVersion),
recommendedJdkVersion: Number(pickFirstValue(req.body.recommendedJdkVersion)), recommendedJdkVersion: Number(pickFirstValue(req.body.recommendedJdkVersion)),
loaderType: pickFirstValue(req.body.loaderType) as PackDefinition['loaderType'],
loaderVersion: pickFirstValue(req.body.loaderVersion),
loaderInstallerPath: pickFirstValue(req.body.loaderInstallerPath),
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)), serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)), serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)), clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
@@ -143,3 +194,101 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
next(error) next(error)
} }
}) })
opRouter.post('/op/dashboard/:packName/assets/loader', requireAuth, upload.single('asset'), async (req, res, next) => {
try {
const packKey = pickFirstValue(req.params.packName)
if (req.file == null) {
throw new Error('업로드된 로더 파일이 없습니다.')
}
const relativePath = await saveUploadedPackAsset(packKey, 'loaders', req.file)
await mutatePackDefinition(packKey, (pack) => {
pack.loaderInstallerPath = relativePath
})
res.redirect(`/op/dashboard/${packKey}`)
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName/assets/resource-pack', requireAuth, upload.single('asset'), async (req, res, next) => {
try {
const packKey = pickFirstValue(req.params.packName)
if (req.file == null) {
throw new Error('업로드된 리소스팩 파일이 없습니다.')
}
const relativePath = await saveUploadedPackAsset(packKey, 'resourcepacks', req.file)
await mutatePackDefinition(packKey, (pack) => {
pack.resourcePackFiles = [...(pack.resourcePackFiles ?? []), relativePath]
})
res.redirect(`/op/dashboard/${packKey}`)
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName/assets/shader-pack', requireAuth, upload.single('asset'), async (req, res, next) => {
try {
const packKey = pickFirstValue(req.params.packName)
if (req.file == null) {
throw new Error('업로드된 쉐이더 파일이 없습니다.')
}
const relativePath = await saveUploadedPackAsset(packKey, 'shaderpacks', req.file)
await mutatePackDefinition(packKey, (pack) => {
pack.shaderPackFiles = [...(pack.shaderPackFiles ?? []), relativePath]
})
res.redirect(`/op/dashboard/${packKey}`)
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName/assets/loader/remove', requireAuth, async (req, res, next) => {
try {
const packKey = pickFirstValue(req.params.packName)
const targetPath = pickFirstValue(req.body.assetPath)
await removeUploadedAsset(targetPath)
await mutatePackDefinition(packKey, (pack) => {
if (pack.loaderInstallerPath === targetPath) {
pack.loaderInstallerPath = ''
}
})
res.redirect(`/op/dashboard/${packKey}`)
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName/assets/resource-pack/remove', requireAuth, async (req, res, next) => {
try {
const packKey = pickFirstValue(req.params.packName)
const targetPath = pickFirstValue(req.body.assetPath)
await removeUploadedAsset(targetPath)
await mutatePackDefinition(packKey, (pack) => {
pack.resourcePackFiles = (pack.resourcePackFiles ?? []).filter((entry) => entry !== targetPath)
})
res.redirect(`/op/dashboard/${packKey}`)
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName/assets/shader-pack/remove', requireAuth, async (req, res, next) => {
try {
const packKey = pickFirstValue(req.params.packName)
const targetPath = pickFirstValue(req.body.assetPath)
await removeUploadedAsset(targetPath)
await mutatePackDefinition(packKey, (pack) => {
pack.shaderPackFiles = (pack.shaderPackFiles ?? []).filter((entry) => entry !== targetPath)
})
res.redirect(`/op/dashboard/${packKey}`)
} catch (error) {
next(error)
}
})

View File

@@ -23,13 +23,18 @@ const defaultAccount: AccountEntry[] = [
const defaultPackDefinition: PackDefinition = { const defaultPackDefinition: PackDefinition = {
mcVersion: '1.20.1', mcVersion: '1.20.1',
recommendedJdkVersion: 17, recommendedJdkVersion: 17,
loaderType: 'vanilla',
loaderVersion: '',
loaderInstallerPath: '',
serverMinRam: 2048, serverMinRam: 2048,
serverMaxRam: 4096, serverMaxRam: 4096,
clientMinRam: 4096, clientMinRam: 4096,
clientRecommendedRam: 8192, clientRecommendedRam: 8192,
packPath: 'sample-pack.zip', packPath: 'sample-pack.zip',
description: '새 서버팩', description: '새 서버팩',
configEditableFiles: ['server.properties', 'bukkit.yml'] configEditableFiles: ['server.properties', 'bukkit.yml'],
resourcePackFiles: [],
shaderPackFiles: []
} }
async function ensureDir(targetPath: string): Promise<void> { async function ensureDir(targetPath: string): Promise<void> {
@@ -215,6 +220,11 @@ export function normalizePackDefinition(input: Partial<PackDefinition>): PackDef
recommendedJdkVersion: Number.isFinite(Number(input.recommendedJdkVersion)) recommendedJdkVersion: Number.isFinite(Number(input.recommendedJdkVersion))
? Number(input.recommendedJdkVersion) ? Number(input.recommendedJdkVersion)
: 17, : 17,
loaderType: ['vanilla', 'forge', 'fabric', 'neoforge'].includes(String(input.loaderType ?? 'vanilla'))
? String(input.loaderType ?? 'vanilla') as PackDefinition['loaderType']
: 'vanilla',
loaderVersion: String(input.loaderVersion ?? '').trim(),
loaderInstallerPath: String(input.loaderInstallerPath ?? '').trim(),
serverMinRam: Number(input.serverMinRam ?? 2048), serverMinRam: Number(input.serverMinRam ?? 2048),
serverMaxRam: Number(input.serverMaxRam ?? 4096), serverMaxRam: Number(input.serverMaxRam ?? 4096),
clientMinRam: Number(input.clientMinRam ?? 4096), clientMinRam: Number(input.clientMinRam ?? 4096),
@@ -226,6 +236,12 @@ export function normalizePackDefinition(input: Partial<PackDefinition>): PackDef
: undefined, : undefined,
configEditableFiles: Array.isArray(input.configEditableFiles) configEditableFiles: Array.isArray(input.configEditableFiles)
? input.configEditableFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0) ? input.configEditableFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
: ['server.properties', 'bukkit.yml'] : ['server.properties', 'bukkit.yml'],
resourcePackFiles: Array.isArray(input.resourcePackFiles)
? input.resourcePackFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
: [],
shaderPackFiles: Array.isArray(input.shaderPackFiles)
? input.shaderPackFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
: []
} }
} }

View File

@@ -14,6 +14,9 @@ export interface RootManifest {
export interface PackDefinition { export interface PackDefinition {
mcVersion: string mcVersion: string
recommendedJdkVersion?: number recommendedJdkVersion?: number
loaderType?: 'vanilla' | 'forge' | 'fabric' | 'neoforge'
loaderVersion?: string
loaderInstallerPath?: string
serverMinRam: number serverMinRam: number
serverMaxRam: number serverMaxRam: number
clientMinRam: number clientMinRam: number
@@ -22,6 +25,8 @@ export interface PackDefinition {
description?: string description?: string
files?: string[] files?: string[]
configEditableFiles?: string[] configEditableFiles?: string[]
resourcePackFiles?: string[]
shaderPackFiles?: string[]
} }
export interface AccountEntry { export interface AccountEntry {

View File

@@ -49,6 +49,23 @@
<span>권장 JDK 버전</span> <span>권장 JDK 버전</span>
<input type="number" name="recommendedJdkVersion" value="<%= pack.recommendedJdkVersion ?? 17 %>" min="8" required /> <input type="number" name="recommendedJdkVersion" value="<%= pack.recommendedJdkVersion ?? 17 %>" min="8" required />
</label> </label>
<label>
<span>클라이언트 로더 종류</span>
<select name="loaderType">
<option value="vanilla" <%= (pack.loaderType ?? 'vanilla') === 'vanilla' ? 'selected' : '' %>>vanilla</option>
<option value="forge" <%= pack.loaderType === 'forge' ? 'selected' : '' %>>forge</option>
<option value="fabric" <%= pack.loaderType === 'fabric' ? 'selected' : '' %>>fabric</option>
<option value="neoforge" <%= pack.loaderType === 'neoforge' ? 'selected' : '' %>>neoforge</option>
</select>
</label>
<label>
<span>로더 버전</span>
<input name="loaderVersion" value="<%= pack.loaderVersion ?? '' %>" placeholder="예: 0.16.14 / 47.3.0 / 21.4.111-beta" />
</label>
<label class="fullSpan">
<span>로더 설치파일 경로</span>
<input name="loaderInstallerPath" value="<%= pack.loaderInstallerPath ?? '' %>" placeholder="예: uploads/sample-pack/loaders/..." />
</label>
<label> <label>
<span>packPath</span> <span>packPath</span>
<input name="packPath" value="<%= pack.packPath %>" required /> <input name="packPath" value="<%= pack.packPath %>" required />
@@ -73,6 +90,63 @@
<button class="primaryButton" type="submit">적용</button> <button class="primaryButton" type="submit">적용</button>
</form> </form>
<section class="assetSection">
<div class="assetCard">
<h2>로더 설치파일 업로드</h2>
<form method="post" action="/op/dashboard/<%= packKey %>/assets/loader" enctype="multipart/form-data" class="assetForm">
<input type="file" name="asset" required />
<button class="primaryButton" type="submit">로더 업로드</button>
</form>
<% if (pack.loaderInstallerPath) { %>
<div class="assetItem">
<code><%= pack.loaderInstallerPath %></code>
<form method="post" action="/op/dashboard/<%= packKey %>/assets/loader/remove">
<input type="hidden" name="assetPath" value="<%= pack.loaderInstallerPath %>" />
<button class="dangerButton" type="submit">삭제</button>
</form>
</div>
<% } %>
</div>
<div class="assetCard">
<h2>리소스팩 업로드</h2>
<form method="post" action="/op/dashboard/<%= packKey %>/assets/resource-pack" enctype="multipart/form-data" class="assetForm">
<input type="file" name="asset" required />
<button class="primaryButton" type="submit">리소스팩 추가</button>
</form>
<div class="assetList">
<% (pack.resourcePackFiles ?? []).forEach((resourcePack) => { %>
<div class="assetItem">
<code><%= resourcePack %></code>
<form method="post" action="/op/dashboard/<%= packKey %>/assets/resource-pack/remove">
<input type="hidden" name="assetPath" value="<%= resourcePack %>" />
<button class="dangerButton" type="submit">삭제</button>
</form>
</div>
<% }) %>
</div>
</div>
<div class="assetCard">
<h2>쉐이더 업로드</h2>
<form method="post" action="/op/dashboard/<%= packKey %>/assets/shader-pack" enctype="multipart/form-data" class="assetForm">
<input type="file" name="asset" required />
<button class="primaryButton" type="submit">쉐이더 추가</button>
</form>
<div class="assetList">
<% (pack.shaderPackFiles ?? []).forEach((shaderPack) => { %>
<div class="assetItem">
<code><%= shaderPack %></code>
<form method="post" action="/op/dashboard/<%= packKey %>/assets/shader-pack/remove">
<input type="hidden" name="assetPath" value="<%= shaderPack %>" />
<button class="dangerButton" type="submit">삭제</button>
</form>
</div>
<% }) %>
</div>
</div>
</section>
</section> </section>
</main> </main>
</body> </body>