Add client apply flow and asset uploads
This commit is contained in:
@@ -44,6 +44,14 @@ function resolveJavaExecutable(jdkPath: string): string {
|
||||
: 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 {
|
||||
const cleaned = rawVersion.trim().replace(/^"+|"+$/g, '')
|
||||
if (cleaned.length === 0) {
|
||||
@@ -255,13 +263,9 @@ function resolveClientRamMb(pack: PackDefinition): { selected: number; warning:
|
||||
throw new Error('플레이 불가: 시스템 램이 최소 램보다 적습니다.')
|
||||
}
|
||||
|
||||
async function writeLauncherProfile(packName: string, installRoot: string, pack: PackDefinition): Promise<void> {
|
||||
const appData = process.env.APPDATA
|
||||
if (appData == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const launcherProfilesPath = path.join(appData, '.minecraft', 'launcher_profiles.json')
|
||||
async function writeLauncherProfile(packName: string, installRoot: string, pack: PackDefinition, lastVersionId: string): Promise<void> {
|
||||
const launcherRoot = getLauncherRootDir()
|
||||
const launcherProfilesPath = path.join(launcherRoot, 'launcher_profiles.json')
|
||||
const gameDir = path.join(installRoot, '.mc_custom')
|
||||
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
|
||||
? payload.profiles as Record<string, unknown>
|
||||
? payload.profiles as Record<string, Record<string, unknown>>
|
||||
: {}
|
||||
|
||||
profiles[packName] = {
|
||||
created: new Date().toISOString(),
|
||||
const existingKey = Object.entries(profiles).find(([, profile]) => profile.name === packName)?.[0] ?? packName
|
||||
profiles[existingKey] = {
|
||||
...(profiles[existingKey] ?? {}),
|
||||
created: String(profiles[existingKey]?.created ?? new Date().toISOString()),
|
||||
gameDir,
|
||||
icon: 'Grass',
|
||||
javaArgs: `-Xms${Math.min(selectedRam, 2048)}M -Xmx${selectedRam}M`,
|
||||
lastVersionId: pack.mcVersion,
|
||||
lastVersionId,
|
||||
name: packName,
|
||||
type: 'custom'
|
||||
}
|
||||
@@ -343,7 +349,6 @@ async function ensureEditableConfigFiles(root: string, pack: PackDefinition): Pr
|
||||
|
||||
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
|
||||
const baseName = path.basename(relativeFile)
|
||||
|
||||
if (baseName === 'server.properties') {
|
||||
await fsp.writeFile(targetPath, [
|
||||
'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 {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
@@ -391,14 +431,14 @@ async function loadMinecraftEulaText(): Promise<string> {
|
||||
return plainText.slice(0, 7000)
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the bundled summary below.
|
||||
// Fall back below.
|
||||
}
|
||||
|
||||
return [
|
||||
'Minecraft EULA 요약',
|
||||
'',
|
||||
'이 설치기는 공식 Minecraft EULA 동의를 받아야만 서버팩 설치를 계속할 수 있습니다.',
|
||||
'상업적 이용, 계정 공유, 저작권 침해 등은 허용되지 않으며, 원문은 아래 주소에서 확인할 수 있습니다.',
|
||||
'원문은 아래 주소에서 확인할 수 있습니다.',
|
||||
'',
|
||||
MINECRAFT_EULA_URL
|
||||
].join('\n')
|
||||
@@ -441,6 +481,8 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
|
||||
|
||||
const extractedRoot = await downloadAndExtractPack(packMeta.baseUrl, packMeta.packDefinition, payload.installPath)
|
||||
await ensureEditableConfigFiles(extractedRoot, packMeta.packDefinition)
|
||||
await patchRunBatFiles(extractedRoot, packMeta.packDefinition)
|
||||
|
||||
const eulaPath = path.join(extractedRoot, 'eula.txt')
|
||||
if (fs.existsSync(eulaPath)) {
|
||||
await fsp.unlink(eulaPath)
|
||||
@@ -448,6 +490,7 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
|
||||
|
||||
currentInstall = {
|
||||
manifestUrl: payload.manifestUrl,
|
||||
baseUrl: packMeta.baseUrl,
|
||||
packFile: payload.packFile,
|
||||
installPath: payload.installPath,
|
||||
jdkPath: payload.jdkPath,
|
||||
@@ -459,8 +502,6 @@ async function startInstall(payload: InstallPayload): Promise<{ nextStep: number
|
||||
await waitForEulaAcceptance()
|
||||
await fsp.writeFile(eulaPath, 'eula=true\n', 'utf8')
|
||||
sendLog('EULA 동의 반영 완료', 'success')
|
||||
await writeLauncherProfile(packMeta.packName, payload.installPath, packMeta.packDefinition)
|
||||
sendLog('Minecraft 런처 프로필 추가 완료', 'success')
|
||||
|
||||
return {
|
||||
nextStep: 5,
|
||||
@@ -608,7 +649,7 @@ function renderConfigEditorPage(): string {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>서버 설정 편집기</title>
|
||||
<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);}
|
||||
.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);}
|
||||
@@ -655,12 +696,7 @@ function renderConfigEditorPage(): string {
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
const state = {
|
||||
directory: '',
|
||||
selectedFile: '',
|
||||
saveTimer: null
|
||||
}
|
||||
|
||||
const state = { directory: '', selectedFile: '', saveTimer: null }
|
||||
const entryList = document.getElementById('entryList')
|
||||
const currentPath = document.getElementById('currentPath')
|
||||
const crumbs = document.getElementById('crumbs')
|
||||
@@ -674,9 +710,7 @@ function renderConfigEditorPage(): string {
|
||||
|
||||
async function loadDirectory(relativePath = '') {
|
||||
const response = await fetch('/api/list?path=' + encodeURIComponent(relativePath))
|
||||
if (!response.ok) {
|
||||
throw new Error('목록을 불러오지 못했습니다.')
|
||||
}
|
||||
if (!response.ok) throw new Error('목록을 불러오지 못했습니다.')
|
||||
const payload = await response.json()
|
||||
state.directory = payload.relativePath
|
||||
currentPath.textContent = payload.relativePath || '루트'
|
||||
@@ -703,9 +737,7 @@ function renderConfigEditorPage(): string {
|
||||
|
||||
async function loadFile(relativePath) {
|
||||
const response = await fetch('/api/file?path=' + encodeURIComponent(relativePath))
|
||||
if (!response.ok) {
|
||||
throw new Error('파일을 열지 못했습니다.')
|
||||
}
|
||||
if (!response.ok) throw new Error('파일을 열지 못했습니다.')
|
||||
const payload = await response.json()
|
||||
state.selectedFile = payload.relativePath
|
||||
currentPath.textContent = payload.relativePath
|
||||
@@ -722,24 +754,17 @@ function renderConfigEditorPage(): string {
|
||||
textarea.value = payload.content
|
||||
textarea.addEventListener('input', () => {
|
||||
setStatus('변경 감지됨. 자동 저장 중...')
|
||||
if (state.saveTimer != null) {
|
||||
clearTimeout(state.saveTimer)
|
||||
}
|
||||
if (state.saveTimer != null) clearTimeout(state.saveTimer)
|
||||
state.saveTimer = setTimeout(async () => {
|
||||
const saveResponse = await fetch('/api/file', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: state.selectedFile,
|
||||
content: textarea.value
|
||||
})
|
||||
body: JSON.stringify({ path: state.selectedFile, content: textarea.value })
|
||||
})
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
setStatus('저장 실패', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('저장 완료', 'ok')
|
||||
}, 300)
|
||||
})
|
||||
@@ -752,11 +777,7 @@ function renderConfigEditorPage(): string {
|
||||
})
|
||||
|
||||
document.getElementById('upButton').addEventListener('click', () => {
|
||||
if (!state.directory) {
|
||||
loadDirectory('').catch((error) => setStatus(error.message, 'error'))
|
||||
return
|
||||
}
|
||||
const next = state.directory.split('/').slice(0, -1).join('/')
|
||||
const next = state.directory ? state.directory.split('/').slice(0, -1).join('/') : ''
|
||||
loadDirectory(next).catch((error) => setStatus(error.message, 'error'))
|
||||
})
|
||||
|
||||
@@ -902,14 +923,177 @@ async function openInstalledFolder(): Promise<void> {
|
||||
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> {
|
||||
const entries = await fsp.readdir(root, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.jar')) {
|
||||
return path.join(root, entry.name)
|
||||
const candidates = await listFilesRecursively(root, (_entryPath, entryName) => entryName.toLowerCase().endsWith('.jar'))
|
||||
return candidates[0] ?? null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
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> {
|
||||
@@ -917,8 +1101,20 @@ async function createDesktopShortcut(enabled: boolean): Promise<void> {
|
||||
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 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)
|
||||
if (serverJar == null) {
|
||||
return
|
||||
@@ -926,10 +1122,9 @@ async function createDesktopShortcut(enabled: boolean): Promise<void> {
|
||||
|
||||
const contents = [
|
||||
'@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`
|
||||
].join('\r\n')
|
||||
|
||||
await fsp.writeFile(shortcutPath, contents, 'utf8')
|
||||
}
|
||||
|
||||
@@ -938,6 +1133,15 @@ async function runServer(enabled: boolean): Promise<void> {
|
||||
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)
|
||||
if (serverJar == null) {
|
||||
sendLog('서버 JAR을 찾지 못해 자동 실행을 생략합니다.', 'warn')
|
||||
@@ -952,7 +1156,7 @@ async function runServer(enabled: boolean): Promise<void> {
|
||||
serverJar,
|
||||
'nogui'
|
||||
], {
|
||||
cwd: currentInstall.extractedRoot
|
||||
cwd: path.dirname(serverJar)
|
||||
})
|
||||
sendLog('서버 실행 시작', 'success')
|
||||
}
|
||||
@@ -982,6 +1186,7 @@ function bindIpcHandlers() {
|
||||
})
|
||||
ipcMain.handle('installer:open-config-editor', async () => openConfigEditor())
|
||||
ipcMain.handle('installer:configure-port', async () => configurePort())
|
||||
ipcMain.handle('installer:apply-client', async () => applyClientConfiguration())
|
||||
ipcMain.handle('installer:open-folder', async () => openInstalledFolder())
|
||||
ipcMain.handle('installer:create-shortcut', async (_event, enabled: boolean) => createDesktopShortcut(enabled))
|
||||
ipcMain.handle('installer:run-server', async (_event, enabled: boolean) => runServer(enabled))
|
||||
|
||||
@@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld('installerApi', {
|
||||
acceptEula: () => ipcRenderer.invoke('installer:accept-eula'),
|
||||
openConfigEditor: () => ipcRenderer.invoke('installer:open-config-editor'),
|
||||
configurePort: () => ipcRenderer.invoke('installer:configure-port'),
|
||||
applyClient: () => ipcRenderer.invoke('installer:apply-client'),
|
||||
openFolder: () => ipcRenderer.invoke('installer:open-folder'),
|
||||
createShortcut: (enabled: boolean) => ipcRenderer.invoke('installer:create-shortcut', enabled),
|
||||
runServer: (enabled: boolean) => ipcRenderer.invoke('installer:run-server', enabled),
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface InstallPayload {
|
||||
|
||||
export interface InstallSessionState {
|
||||
manifestUrl: string
|
||||
baseUrl: string
|
||||
packFile: string
|
||||
installPath: string
|
||||
jdkPath: string
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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 { fileDir } from '../../shared/paths'
|
||||
import {
|
||||
createNewPack,
|
||||
deletePacks,
|
||||
@@ -8,11 +13,14 @@ import {
|
||||
loadPackDefinition,
|
||||
loadRootManifest,
|
||||
normalizePackDefinition,
|
||||
savePackDefinition,
|
||||
updatePack
|
||||
} from '../../shared/store'
|
||||
import { PackDefinition } from '../../shared/types'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
|
||||
export const opRouter = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage() })
|
||||
|
||||
function pickFirstValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
@@ -21,6 +29,41 @@ function pickFirstValue(value: unknown): string {
|
||||
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) => {
|
||||
if (req.session.userId != null) {
|
||||
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 nextPackName = pickFirstValue(req.body.displayName).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({
|
||||
...currentDefinition,
|
||||
mcVersion: pickFirstValue(req.body.mcVersion),
|
||||
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)),
|
||||
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
||||
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
||||
@@ -143,3 +194,101 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,13 +23,18 @@ const defaultAccount: AccountEntry[] = [
|
||||
const defaultPackDefinition: PackDefinition = {
|
||||
mcVersion: '1.20.1',
|
||||
recommendedJdkVersion: 17,
|
||||
loaderType: 'vanilla',
|
||||
loaderVersion: '',
|
||||
loaderInstallerPath: '',
|
||||
serverMinRam: 2048,
|
||||
serverMaxRam: 4096,
|
||||
clientMinRam: 4096,
|
||||
clientRecommendedRam: 8192,
|
||||
packPath: 'sample-pack.zip',
|
||||
description: '새 서버팩',
|
||||
configEditableFiles: ['server.properties', 'bukkit.yml']
|
||||
configEditableFiles: ['server.properties', 'bukkit.yml'],
|
||||
resourcePackFiles: [],
|
||||
shaderPackFiles: []
|
||||
}
|
||||
|
||||
async function ensureDir(targetPath: string): Promise<void> {
|
||||
@@ -215,6 +220,11 @@ export function normalizePackDefinition(input: Partial<PackDefinition>): PackDef
|
||||
recommendedJdkVersion: Number.isFinite(Number(input.recommendedJdkVersion))
|
||||
? Number(input.recommendedJdkVersion)
|
||||
: 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),
|
||||
serverMaxRam: Number(input.serverMaxRam ?? 4096),
|
||||
clientMinRam: Number(input.clientMinRam ?? 4096),
|
||||
@@ -226,6 +236,12 @@ export function normalizePackDefinition(input: Partial<PackDefinition>): PackDef
|
||||
: undefined,
|
||||
configEditableFiles: Array.isArray(input.configEditableFiles)
|
||||
? 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)
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface RootManifest {
|
||||
export interface PackDefinition {
|
||||
mcVersion: string
|
||||
recommendedJdkVersion?: number
|
||||
loaderType?: 'vanilla' | 'forge' | 'fabric' | 'neoforge'
|
||||
loaderVersion?: string
|
||||
loaderInstallerPath?: string
|
||||
serverMinRam: number
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
@@ -22,6 +25,8 @@ export interface PackDefinition {
|
||||
description?: string
|
||||
files?: string[]
|
||||
configEditableFiles?: string[]
|
||||
resourcePackFiles?: string[]
|
||||
shaderPackFiles?: string[]
|
||||
}
|
||||
|
||||
export interface AccountEntry {
|
||||
|
||||
Reference in New Issue
Block a user